@dcrays/scheduled-task 0.1.4 → 0.1.5
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/cordis.patch.yml +0 -1
- package/package.json +1 -1
- package/plugin/index.d.ts +0 -1
- package/plugin/index.js +187 -65
- package/src/cron.js +3 -1
- package/src/manager.d.ts +14 -0
- package/src/manager.js +166 -64
- package/src/openclaw-sqlite.d.ts +2 -0
- package/src/openclaw-sqlite.js +3 -3
- package/src/repository.d.ts +2 -0
- package/src/repository.js +21 -0
package/cordis.patch.yml
CHANGED
package/package.json
CHANGED
package/plugin/index.d.ts
CHANGED
package/plugin/index.js
CHANGED
|
@@ -34,7 +34,7 @@ function parseInteger(value, label) {
|
|
|
34
34
|
}
|
|
35
35
|
function parseField(raw, min, max, label, normalize) {
|
|
36
36
|
const values = /* @__PURE__ */ new Set();
|
|
37
|
-
const wildcard = raw === "*"
|
|
37
|
+
const wildcard = raw === "*";
|
|
38
38
|
if (raw.length === 0)
|
|
39
39
|
throw new CronExpressionError(`${label} is empty`);
|
|
40
40
|
for (const segment of raw.split(",")) {
|
|
@@ -634,6 +634,24 @@ var CronAutomationRepository = class {
|
|
|
634
634
|
return [];
|
|
635
635
|
return this.database.prepare("SELECT source, job_id FROM cron_imported_jobs").all().map((row) => ({ source: row.source, jobId: row.job_id }));
|
|
636
636
|
}
|
|
637
|
+
/** Only a verified complete source snapshot may authorize these deletions. */
|
|
638
|
+
async removeMissingImportedJobs(source, presentIds) {
|
|
639
|
+
this.assertWritable();
|
|
640
|
+
if (!source.trim())
|
|
641
|
+
throw new Error("cron import source must be non-empty");
|
|
642
|
+
return this.writeTransaction(() => {
|
|
643
|
+
const rows = this.database.prepare(`
|
|
644
|
+
SELECT jobs.job_id FROM cron_jobs AS jobs
|
|
645
|
+
INNER JOIN cron_imported_jobs AS imported ON imported.job_id = jobs.job_id
|
|
646
|
+
WHERE jobs.store_key = ? AND imported.source = ?
|
|
647
|
+
`).all(STORE_KEY, source);
|
|
648
|
+
const removed = rows.map((row) => row.job_id).filter((id) => !presentIds.has(id));
|
|
649
|
+
const statement = this.database.prepare("DELETE FROM cron_jobs WHERE store_key = ? AND job_id = ?");
|
|
650
|
+
for (const id of removed)
|
|
651
|
+
statement.run(STORE_KEY, id);
|
|
652
|
+
return removed;
|
|
653
|
+
});
|
|
654
|
+
}
|
|
637
655
|
async migrateLegacy(migrationId, incoming, importedJobs) {
|
|
638
656
|
this.assertWritable();
|
|
639
657
|
const decoded = uniqueJobs(incoming);
|
|
@@ -716,6 +734,7 @@ var CronAutomationRepository = class {
|
|
|
716
734
|
|
|
717
735
|
// dist-npm/src/manager.js
|
|
718
736
|
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
737
|
+
var DELIVERY_RETRY_DELAY_MS = 3e4;
|
|
719
738
|
function automationId(agentId, requestId) {
|
|
720
739
|
return `cron-${createHash("sha256").update(`${agentId}\0${requestId}`).digest("hex").slice(0, 24)}`;
|
|
721
740
|
}
|
|
@@ -802,15 +821,25 @@ var CronAutomationManager = class {
|
|
|
802
821
|
options;
|
|
803
822
|
jobs = /* @__PURE__ */ new Map();
|
|
804
823
|
blockedAgents = /* @__PURE__ */ new Set();
|
|
824
|
+
blockedUntil = /* @__PURE__ */ new Map();
|
|
825
|
+
activeJobs = /* @__PURE__ */ new Set();
|
|
826
|
+
inflight = /* @__PURE__ */ new Set();
|
|
805
827
|
now;
|
|
806
828
|
started;
|
|
807
829
|
tail = Promise.resolve();
|
|
830
|
+
dispatchLoop = Promise.resolve();
|
|
808
831
|
timer;
|
|
809
832
|
stopping = false;
|
|
833
|
+
schedulingPaused;
|
|
810
834
|
constructor(repository, options) {
|
|
811
835
|
this.repository = repository;
|
|
812
836
|
this.options = options;
|
|
813
837
|
this.now = options.now ?? Date.now;
|
|
838
|
+
this.schedulingPaused = options.startPaused ?? false;
|
|
839
|
+
}
|
|
840
|
+
resumeScheduling() {
|
|
841
|
+
this.schedulingPaused = false;
|
|
842
|
+
this.arm();
|
|
814
843
|
}
|
|
815
844
|
start() {
|
|
816
845
|
return this.started ??= this.initialize();
|
|
@@ -842,6 +871,8 @@ var CronAutomationManager = class {
|
|
|
842
871
|
clearTimeout(this.timer);
|
|
843
872
|
this.timer = void 0;
|
|
844
873
|
try {
|
|
874
|
+
await this.dispatchLoop.catch(() => void 0);
|
|
875
|
+
await Promise.all([...this.inflight]);
|
|
845
876
|
await this.tail;
|
|
846
877
|
} finally {
|
|
847
878
|
this.repository.close();
|
|
@@ -1012,31 +1043,12 @@ var CronAutomationManager = class {
|
|
|
1012
1043
|
}
|
|
1013
1044
|
async runOnceStatus(jobId) {
|
|
1014
1045
|
await this.start();
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
if (!hasDshSession(job))
|
|
1022
|
-
return "delivery_unavailable";
|
|
1023
|
-
const occurrenceAtMs = this.now();
|
|
1024
|
-
job.state.runningAtMs = occurrenceAtMs;
|
|
1025
|
-
job.updatedAtMs = occurrenceAtMs;
|
|
1026
|
-
await this.repository.upsert([job]);
|
|
1027
|
-
const delivered = await this.deliver(job, occurrenceAtMs);
|
|
1028
|
-
delete job.state.runningAtMs;
|
|
1029
|
-
job.state.lastRunAtMs = occurrenceAtMs;
|
|
1030
|
-
job.state.lastRunStatus = delivered ? "ok" : "skipped";
|
|
1031
|
-
job.updatedAtMs = this.now();
|
|
1032
|
-
await this.repository.upsert([job]);
|
|
1033
|
-
if (delivered) {
|
|
1034
|
-
this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
1035
|
-
return "dispatched";
|
|
1036
|
-
}
|
|
1037
|
-
this.options.onChanged?.(cloneJob(job));
|
|
1038
|
-
return "delivery_unavailable";
|
|
1039
|
-
});
|
|
1046
|
+
const claimed = await this.enqueue(() => this.claimManualRun(jobId));
|
|
1047
|
+
if (claimed.kind === "status")
|
|
1048
|
+
return claimed.status;
|
|
1049
|
+
const delivered = await this.deliverTracked(claimed.job, claimed.occurrenceAtMs);
|
|
1050
|
+
await this.enqueue(() => this.finalizeManualRun(claimed.job.id, claimed.occurrenceAtMs, delivered));
|
|
1051
|
+
return delivered ? "dispatched" : "delivery_unavailable";
|
|
1040
1052
|
}
|
|
1041
1053
|
async runOnce(jobId) {
|
|
1042
1054
|
return await this.runOnceStatus(jobId) === "dispatched";
|
|
@@ -1047,10 +1059,25 @@ var CronAutomationManager = class {
|
|
|
1047
1059
|
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);
|
|
1048
1060
|
}
|
|
1049
1061
|
notifyAgentAvailable(agentId) {
|
|
1050
|
-
|
|
1051
|
-
|
|
1062
|
+
this.blockedAgents.delete(agentId);
|
|
1063
|
+
this.blockedUntil.delete(agentId);
|
|
1052
1064
|
this.requestDispatch();
|
|
1053
1065
|
}
|
|
1066
|
+
/** Reflect deletions only for ids recorded in this source's import ledger. */
|
|
1067
|
+
async reconcileImportedDeletions(source, presentIds) {
|
|
1068
|
+
await this.start();
|
|
1069
|
+
return this.enqueue(async () => {
|
|
1070
|
+
const removed = await this.repository.removeMissingImportedJobs(source, presentIds);
|
|
1071
|
+
for (const id of removed) {
|
|
1072
|
+
const job = this.jobs.get(id);
|
|
1073
|
+
this.jobs.delete(id);
|
|
1074
|
+
if (job)
|
|
1075
|
+
this.options.onChanged?.(cloneJob(job));
|
|
1076
|
+
}
|
|
1077
|
+
this.arm();
|
|
1078
|
+
return removed;
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1054
1081
|
/** Import each source job once. Existing ids win so DSH bindings stay intact. */
|
|
1055
1082
|
async importFrom(source, incoming) {
|
|
1056
1083
|
await this.start();
|
|
@@ -1107,22 +1134,124 @@ var CronAutomationManager = class {
|
|
|
1107
1134
|
return false;
|
|
1108
1135
|
}
|
|
1109
1136
|
}
|
|
1137
|
+
async deliverTracked(job, occurrenceAtMs) {
|
|
1138
|
+
const work = this.deliver(job, occurrenceAtMs);
|
|
1139
|
+
this.inflight.add(work);
|
|
1140
|
+
try {
|
|
1141
|
+
return await work;
|
|
1142
|
+
} finally {
|
|
1143
|
+
this.inflight.delete(work);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
async claimManualRun(jobId) {
|
|
1147
|
+
if (this.stopping)
|
|
1148
|
+
return { kind: "status", status: "delivery_unavailable" };
|
|
1149
|
+
if (this.activeJobs.has(jobId))
|
|
1150
|
+
return { kind: "status", status: "already_running" };
|
|
1151
|
+
const job = this.jobs.get(jobId);
|
|
1152
|
+
if (!job)
|
|
1153
|
+
return { kind: "status", status: "not_found" };
|
|
1154
|
+
if (job.state.runningAtMs !== void 0)
|
|
1155
|
+
return { kind: "status", status: "already_running" };
|
|
1156
|
+
if (!hasDshSession(job))
|
|
1157
|
+
return { kind: "status", status: "delivery_unavailable" };
|
|
1158
|
+
const occurrenceAtMs = this.now();
|
|
1159
|
+
this.activeJobs.add(jobId);
|
|
1160
|
+
job.state.runningAtMs = occurrenceAtMs;
|
|
1161
|
+
job.updatedAtMs = occurrenceAtMs;
|
|
1162
|
+
try {
|
|
1163
|
+
await this.repository.upsert([job]);
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
this.activeJobs.delete(jobId);
|
|
1166
|
+
delete job.state.runningAtMs;
|
|
1167
|
+
throw error;
|
|
1168
|
+
}
|
|
1169
|
+
return { kind: "ready", job: cloneJob(job), occurrenceAtMs };
|
|
1170
|
+
}
|
|
1171
|
+
async finalizeManualRun(jobId, occurrenceAtMs, delivered) {
|
|
1172
|
+
this.activeJobs.delete(jobId);
|
|
1173
|
+
const job = this.jobs.get(jobId);
|
|
1174
|
+
if (!job)
|
|
1175
|
+
return;
|
|
1176
|
+
delete job.state.runningAtMs;
|
|
1177
|
+
job.state.lastRunAtMs = occurrenceAtMs;
|
|
1178
|
+
job.state.lastRunStatus = delivered ? "ok" : "skipped";
|
|
1179
|
+
job.updatedAtMs = this.now();
|
|
1180
|
+
await this.repository.upsert([job]);
|
|
1181
|
+
if (delivered)
|
|
1182
|
+
this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
1183
|
+
else
|
|
1184
|
+
this.options.onChanged?.(cloneJob(job));
|
|
1185
|
+
}
|
|
1186
|
+
async claimNextDueJob() {
|
|
1187
|
+
if (this.stopping || this.schedulingPaused)
|
|
1188
|
+
return void 0;
|
|
1189
|
+
const now = this.now();
|
|
1190
|
+
const job = [...this.jobs.values()].filter((candidate) => candidate.enabled && hasDshSession(candidate) && candidate.state.runningAtMs === void 0 && !this.activeJobs.has(candidate.id) && candidate.state.nextRunAtMs !== void 0 && candidate.state.nextRunAtMs <= now && (!this.blockedAgents.has(dshAgentIdFor(candidate)) || (this.blockedUntil.get(dshAgentIdFor(candidate)) ?? 0) <= now)).sort((left, right) => (left.state.nextRunAtMs ?? 0) - (right.state.nextRunAtMs ?? 0) || left.createdAtMs - right.createdAtMs)[0];
|
|
1191
|
+
if (!job)
|
|
1192
|
+
return void 0;
|
|
1193
|
+
const occurrenceAtMs = job.state.nextRunAtMs ?? now;
|
|
1194
|
+
this.activeJobs.add(job.id);
|
|
1195
|
+
job.state.runningAtMs = now;
|
|
1196
|
+
job.updatedAtMs = now;
|
|
1197
|
+
try {
|
|
1198
|
+
await this.repository.upsert([job]);
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
this.activeJobs.delete(job.id);
|
|
1201
|
+
delete job.state.runningAtMs;
|
|
1202
|
+
throw error;
|
|
1203
|
+
}
|
|
1204
|
+
return { job: cloneJob(job), occurrenceAtMs };
|
|
1205
|
+
}
|
|
1206
|
+
async finalizeScheduledRun(jobId, occurrenceAtMs, delivered) {
|
|
1207
|
+
this.activeJobs.delete(jobId);
|
|
1208
|
+
const job = this.jobs.get(jobId);
|
|
1209
|
+
if (!job)
|
|
1210
|
+
return;
|
|
1211
|
+
const now = this.now();
|
|
1212
|
+
delete job.state.runningAtMs;
|
|
1213
|
+
job.state.lastRunAtMs = now;
|
|
1214
|
+
job.state.lastRunStatus = delivered ? "ok" : "skipped";
|
|
1215
|
+
job.updatedAtMs = now;
|
|
1216
|
+
if (!delivered) {
|
|
1217
|
+
this.blockedAgents.add(dshAgentIdFor(job));
|
|
1218
|
+
this.blockedUntil.set(dshAgentIdFor(job), now + DELIVERY_RETRY_DELAY_MS);
|
|
1219
|
+
await this.repository.upsert([job]);
|
|
1220
|
+
this.options.onChanged?.(cloneJob(job));
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
this.blockedAgents.delete(dshAgentIdFor(job));
|
|
1224
|
+
this.blockedUntil.delete(dshAgentIdFor(job));
|
|
1225
|
+
if (job.deleteAfterRun === true || job.schedule.kind === "at") {
|
|
1226
|
+
this.jobs.delete(jobId);
|
|
1227
|
+
await this.repository.delete(jobId);
|
|
1228
|
+
} else {
|
|
1229
|
+
if (job.enabled)
|
|
1230
|
+
setNextOccurrence(job, nextOccurrence(now, job.schedule));
|
|
1231
|
+
await this.repository.upsert([job]);
|
|
1232
|
+
}
|
|
1233
|
+
this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
1234
|
+
}
|
|
1110
1235
|
enqueue(operation) {
|
|
1111
1236
|
const run = this.tail.then(operation);
|
|
1112
1237
|
this.tail = run.then(() => void 0, () => void 0);
|
|
1113
1238
|
return run;
|
|
1114
1239
|
}
|
|
1115
1240
|
requestDispatch() {
|
|
1116
|
-
|
|
1241
|
+
this.dispatchLoop = this.dispatchLoop.then(() => this.dispatchDue(), () => this.dispatchDue());
|
|
1242
|
+
void this.dispatchLoop.catch((error) => this.options.onError?.(error));
|
|
1117
1243
|
}
|
|
1118
1244
|
arm() {
|
|
1119
|
-
if (this.stopping)
|
|
1245
|
+
if (this.stopping || this.schedulingPaused)
|
|
1120
1246
|
return;
|
|
1121
1247
|
if (this.timer)
|
|
1122
1248
|
clearTimeout(this.timer);
|
|
1123
1249
|
this.timer = void 0;
|
|
1124
1250
|
const now = this.now();
|
|
1125
|
-
const target = [...this.jobs.values()].filter((job) => job.enabled && hasDshSession(job) && job.state.runningAtMs === void 0 && !this.blockedAgents.has(dshAgentIdFor(job))
|
|
1251
|
+
const target = [...this.jobs.values()].filter((job) => job.enabled && hasDshSession(job) && job.state.runningAtMs === void 0 && (!this.blockedAgents.has(dshAgentIdFor(job)) || (this.blockedUntil.get(dshAgentIdFor(job)) ?? 0) <= now)).map((job) => {
|
|
1252
|
+
const retryAt = this.blockedUntil.get(dshAgentIdFor(job));
|
|
1253
|
+
return retryAt !== void 0 && retryAt > now ? retryAt : job.state.nextRunAtMs;
|
|
1254
|
+
}).filter((value) => value !== void 0).reduce((earliest, candidate) => earliest === void 0 || candidate < earliest ? candidate : earliest, void 0);
|
|
1126
1255
|
if (target === void 0)
|
|
1127
1256
|
return;
|
|
1128
1257
|
const delay = Math.max(0, Math.min(target - now, MAX_TIMER_DELAY_MS));
|
|
@@ -1133,36 +1262,18 @@ var CronAutomationManager = class {
|
|
|
1133
1262
|
this.timer.unref();
|
|
1134
1263
|
}
|
|
1135
1264
|
async dispatchDue() {
|
|
1136
|
-
if (this.stopping)
|
|
1265
|
+
if (this.stopping || this.schedulingPaused)
|
|
1137
1266
|
return;
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
job.
|
|
1144
|
-
await this.repository.upsert([job]);
|
|
1145
|
-
const delivered = await this.deliver(job, occurrenceAtMs);
|
|
1146
|
-
delete job.state.runningAtMs;
|
|
1147
|
-
job.state.lastRunAtMs = now;
|
|
1148
|
-
job.state.lastRunStatus = delivered ? "ok" : "skipped";
|
|
1149
|
-
job.updatedAtMs = now;
|
|
1150
|
-
if (!delivered) {
|
|
1151
|
-
this.blockedAgents.add(dshAgentIdFor(job));
|
|
1152
|
-
await this.repository.upsert([job]);
|
|
1153
|
-
this.options.onChanged?.(cloneJob(job));
|
|
1154
|
-
continue;
|
|
1155
|
-
}
|
|
1156
|
-
if (job.deleteAfterRun === true || job.schedule.kind === "at") {
|
|
1157
|
-
this.jobs.delete(job.id);
|
|
1158
|
-
await this.repository.delete(job.id);
|
|
1159
|
-
} else {
|
|
1160
|
-
setNextOccurrence(job, nextOccurrence(now, job.schedule));
|
|
1161
|
-
await this.repository.upsert([job]);
|
|
1162
|
-
}
|
|
1163
|
-
this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
1267
|
+
for (; ; ) {
|
|
1268
|
+
const claimed = await this.enqueue(() => this.claimNextDueJob());
|
|
1269
|
+
if (!claimed)
|
|
1270
|
+
break;
|
|
1271
|
+
const delivered = await this.deliverTracked(claimed.job, claimed.occurrenceAtMs);
|
|
1272
|
+
await this.enqueue(() => this.finalizeScheduledRun(claimed.job.id, claimed.occurrenceAtMs, delivered));
|
|
1164
1273
|
}
|
|
1165
|
-
this.
|
|
1274
|
+
await this.enqueue(async () => {
|
|
1275
|
+
this.arm();
|
|
1276
|
+
});
|
|
1166
1277
|
}
|
|
1167
1278
|
};
|
|
1168
1279
|
|
|
@@ -1268,12 +1379,12 @@ function jobFromOpenClawRow(row) {
|
|
|
1268
1379
|
}
|
|
1269
1380
|
function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
|
|
1270
1381
|
if (!existsSync(sqlitePath))
|
|
1271
|
-
return { jobs: [], skipped: [] };
|
|
1382
|
+
return { jobs: [], skipped: [], complete: false };
|
|
1272
1383
|
const database = new DatabaseSync2(sqlitePath, { readOnly: true, timeout: 5e3 });
|
|
1273
1384
|
try {
|
|
1274
1385
|
const table = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_jobs'").get();
|
|
1275
1386
|
if (!table)
|
|
1276
|
-
return { jobs: [], skipped: [] };
|
|
1387
|
+
return { jobs: [], skipped: [], complete: false };
|
|
1277
1388
|
const rows = database.prepare("SELECT * FROM cron_jobs ORDER BY updated_at ASC, job_id ASC").all();
|
|
1278
1389
|
const jobs = /* @__PURE__ */ new Map();
|
|
1279
1390
|
const skipped = [];
|
|
@@ -1290,7 +1401,7 @@ function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
|
|
|
1290
1401
|
});
|
|
1291
1402
|
}
|
|
1292
1403
|
}
|
|
1293
|
-
return { jobs: [...jobs.values()], skipped };
|
|
1404
|
+
return { jobs: [...jobs.values()], skipped, complete: skipped.length === 0 };
|
|
1294
1405
|
} finally {
|
|
1295
1406
|
database.close();
|
|
1296
1407
|
}
|
|
@@ -1383,7 +1494,6 @@ var Schema = z;
|
|
|
1383
1494
|
var Config = Schema.object({
|
|
1384
1495
|
databaseFile: Schema.string().required(false),
|
|
1385
1496
|
openclawSqlite: Schema.string().required(false),
|
|
1386
|
-
runtimeEnvironment: Schema.string().required(false),
|
|
1387
1497
|
legacyDatabaseEnvironment: Schema.string().required(false),
|
|
1388
1498
|
maxPromptChars: Schema.natural().min(1).default(16384),
|
|
1389
1499
|
listPushIntervalSeconds: Schema.natural().min(1).default(30)
|
|
@@ -1590,11 +1700,12 @@ var CronAutomationService = class extends Service {
|
|
|
1590
1700
|
super(owner, "cronAutomations");
|
|
1591
1701
|
this.owner = owner;
|
|
1592
1702
|
this.config = config;
|
|
1593
|
-
this.runtimeEnvironment = resolveCronRuntimeEnvironment(readDesktopRuntimeEnvironment()
|
|
1703
|
+
this.runtimeEnvironment = resolveCronRuntimeEnvironment(readDesktopRuntimeEnvironment());
|
|
1594
1704
|
const databaseFile = resolveCronDatabaseFile(config.databaseFile, this.runtimeEnvironment);
|
|
1595
1705
|
const logger = owner.logger("dsh-cron-automation");
|
|
1596
1706
|
this.manager = new CronAutomationManager(new CronAutomationRepository(databaseFile), {
|
|
1597
1707
|
maxPromptChars: config.maxPromptChars,
|
|
1708
|
+
startPaused: true,
|
|
1598
1709
|
deliver: async (cron, occurrenceAt) => {
|
|
1599
1710
|
if (!cron.dshSessionId) {
|
|
1600
1711
|
logger.warn("cron %s is waiting for an MBHChat DSH session binding", cron.id);
|
|
@@ -1612,6 +1723,7 @@ var CronAutomationService = class extends Service {
|
|
|
1612
1723
|
return false;
|
|
1613
1724
|
}
|
|
1614
1725
|
agent.followup(dueMessage(cron, occurrenceAt));
|
|
1726
|
+
await agent.whenIdle();
|
|
1615
1727
|
return true;
|
|
1616
1728
|
},
|
|
1617
1729
|
onError: (error) => logger.warn("cron automation runtime failed: %s", error instanceof Error ? error.message : String(error)),
|
|
@@ -1636,6 +1748,7 @@ var CronAutomationService = class extends Service {
|
|
|
1636
1748
|
await this.manager.start();
|
|
1637
1749
|
await this.migrateLegacyCronDatabase();
|
|
1638
1750
|
await this.importOpenClawJobs();
|
|
1751
|
+
this.manager.resumeScheduling();
|
|
1639
1752
|
await this.publishList("startup");
|
|
1640
1753
|
if (this.stopping)
|
|
1641
1754
|
return;
|
|
@@ -1700,11 +1813,18 @@ var CronAutomationService = class extends Service {
|
|
|
1700
1813
|
this.owner.logger("dsh-cron-automation").warn("skipped %s invalid OpenClaw cron job(s) from %s: %s", result.skipped.length, sqlitePath, details);
|
|
1701
1814
|
}
|
|
1702
1815
|
const incoming = result.jobs;
|
|
1816
|
+
const source = explicit ? openClawImportSource(sqlitePath) : automaticOpenClawImportSource();
|
|
1817
|
+
if (result.complete) {
|
|
1818
|
+
const removed = await this.manager.reconcileImportedDeletions(source, new Set(incoming.map((job) => job.id)));
|
|
1819
|
+
if (removed.length > 0) {
|
|
1820
|
+
this.owner.logger("dsh-cron-automation").info("removed %s imported cron job(s) deleted in OpenClaw", removed.length);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1703
1823
|
if (incoming.length === 0) {
|
|
1704
1824
|
this.owner.logger("dsh-cron-automation").info("automatic OpenClaw cron migration found no importable jobs in %s", sqlitePath);
|
|
1705
1825
|
return;
|
|
1706
1826
|
}
|
|
1707
|
-
const added = await this.manager.importFrom(
|
|
1827
|
+
const added = await this.manager.importFrom(source, incoming);
|
|
1708
1828
|
this.owner.logger("dsh-cron-automation").info("automatic OpenClaw cron migration completed from %s: %s imported, %s already recorded", sqlitePath, added.length, incoming.length - added.length);
|
|
1709
1829
|
}
|
|
1710
1830
|
async stop() {
|
|
@@ -1751,6 +1871,7 @@ var CronAutomationService = class extends Service {
|
|
|
1751
1871
|
};
|
|
1752
1872
|
}
|
|
1753
1873
|
async handleHttp(req, res) {
|
|
1874
|
+
await this.start();
|
|
1754
1875
|
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
1755
1876
|
if (pathname === API_PREFIX && req.method === "GET") {
|
|
1756
1877
|
sendJson(res, 200, await this.snapshot("subscribe"));
|
|
@@ -1830,6 +1951,7 @@ var CronAutomationService = class extends Service {
|
|
|
1830
1951
|
return;
|
|
1831
1952
|
this.pushQueued = true;
|
|
1832
1953
|
this.pushTail = this.pushTail.then(async () => {
|
|
1954
|
+
await this.start();
|
|
1833
1955
|
while (this.pendingPushReason) {
|
|
1834
1956
|
const pendingReason = this.pendingPushReason;
|
|
1835
1957
|
this.pendingPushReason = void 0;
|
package/src/cron.js
CHANGED
|
@@ -24,7 +24,9 @@ function parseInteger(value, label) {
|
|
|
24
24
|
}
|
|
25
25
|
function parseField(raw, min, max, label, normalize) {
|
|
26
26
|
const values = new Set();
|
|
27
|
-
|
|
27
|
+
// A stepped field such as */2 selects a subset of values. It must remain
|
|
28
|
+
// restricted for cron's day-of-month/day-of-week matching rule.
|
|
29
|
+
const wildcard = raw === "*";
|
|
28
30
|
if (raw.length === 0)
|
|
29
31
|
throw new CronExpressionError(`${label} is empty`);
|
|
30
32
|
for (const segment of raw.split(",")) {
|
package/src/manager.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { CronCreateEnvelope } from './protocol.js';
|
|
|
2
2
|
import { CronAutomationRepository, type CronImportedJob, type StoredCronAutomation } from './repository.js';
|
|
3
3
|
export interface CronAutomationManagerOptions {
|
|
4
4
|
maxPromptChars: number;
|
|
5
|
+
startPaused?: boolean;
|
|
5
6
|
now?: () => number;
|
|
6
7
|
deliver(job: StoredCronAutomation, occurrenceAt: string): boolean | Promise<boolean>;
|
|
7
8
|
onError?(error: unknown): void;
|
|
@@ -25,12 +26,18 @@ export declare class CronAutomationManager {
|
|
|
25
26
|
private readonly options;
|
|
26
27
|
private readonly jobs;
|
|
27
28
|
private readonly blockedAgents;
|
|
29
|
+
private readonly blockedUntil;
|
|
30
|
+
private readonly activeJobs;
|
|
31
|
+
private readonly inflight;
|
|
28
32
|
private readonly now;
|
|
29
33
|
private started?;
|
|
30
34
|
private tail;
|
|
35
|
+
private dispatchLoop;
|
|
31
36
|
private timer;
|
|
32
37
|
private stopping;
|
|
38
|
+
private schedulingPaused;
|
|
33
39
|
constructor(repository: CronAutomationRepository, options: CronAutomationManagerOptions);
|
|
40
|
+
resumeScheduling(): void;
|
|
34
41
|
start(): Promise<void>;
|
|
35
42
|
private initialize;
|
|
36
43
|
stop(): Promise<void>;
|
|
@@ -41,6 +48,8 @@ export declare class CronAutomationManager {
|
|
|
41
48
|
runOnce(jobId: string): Promise<boolean>;
|
|
42
49
|
list(agentId?: string): Promise<StoredCronAutomation[]>;
|
|
43
50
|
notifyAgentAvailable(agentId: string): void;
|
|
51
|
+
/** Reflect deletions only for ids recorded in this source's import ledger. */
|
|
52
|
+
reconcileImportedDeletions(source: string, presentIds: ReadonlySet<string>): Promise<string[]>;
|
|
44
53
|
/** Import each source job once. Existing ids win so DSH bindings stay intact. */
|
|
45
54
|
importFrom(source: string, incoming: readonly StoredCronAutomation[]): Promise<StoredCronAutomation[]>;
|
|
46
55
|
migrateLegacy(migrationId: string, incoming: readonly StoredCronAutomation[], importedJobs: readonly CronImportedJob[]): Promise<{
|
|
@@ -49,6 +58,11 @@ export declare class CronAutomationManager {
|
|
|
49
58
|
}>;
|
|
50
59
|
private prepareImportedJob;
|
|
51
60
|
private deliver;
|
|
61
|
+
private deliverTracked;
|
|
62
|
+
private claimManualRun;
|
|
63
|
+
private finalizeManualRun;
|
|
64
|
+
private claimNextDueJob;
|
|
65
|
+
private finalizeScheduledRun;
|
|
52
66
|
private enqueue;
|
|
53
67
|
private requestDispatch;
|
|
54
68
|
private arm;
|
package/src/manager.js
CHANGED
|
@@ -3,6 +3,7 @@ import { assertMinimumCronInterval, CronExpressionError, MIN_CRON_INTERVAL_SECON
|
|
|
3
3
|
import { CronAutomationInputError } from './protocol.js';
|
|
4
4
|
import { CronAutomationRepository } from './repository.js';
|
|
5
5
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
6
|
+
const DELIVERY_RETRY_DELAY_MS = 30_000;
|
|
6
7
|
function automationId(agentId, requestId) {
|
|
7
8
|
return `cron-${createHash('sha256').update(`${agentId}\0${requestId}`).digest('hex').slice(0, 24)}`;
|
|
8
9
|
}
|
|
@@ -96,15 +97,25 @@ export class CronAutomationManager {
|
|
|
96
97
|
options;
|
|
97
98
|
jobs = new Map();
|
|
98
99
|
blockedAgents = new Set();
|
|
100
|
+
blockedUntil = new Map();
|
|
101
|
+
activeJobs = new Set();
|
|
102
|
+
inflight = new Set();
|
|
99
103
|
now;
|
|
100
104
|
started;
|
|
101
105
|
tail = Promise.resolve();
|
|
106
|
+
dispatchLoop = Promise.resolve();
|
|
102
107
|
timer;
|
|
103
108
|
stopping = false;
|
|
109
|
+
schedulingPaused;
|
|
104
110
|
constructor(repository, options) {
|
|
105
111
|
this.repository = repository;
|
|
106
112
|
this.options = options;
|
|
107
113
|
this.now = options.now ?? Date.now;
|
|
114
|
+
this.schedulingPaused = options.startPaused ?? false;
|
|
115
|
+
}
|
|
116
|
+
resumeScheduling() {
|
|
117
|
+
this.schedulingPaused = false;
|
|
118
|
+
this.arm();
|
|
108
119
|
}
|
|
109
120
|
start() {
|
|
110
121
|
return (this.started ??= this.initialize());
|
|
@@ -136,6 +147,8 @@ export class CronAutomationManager {
|
|
|
136
147
|
clearTimeout(this.timer);
|
|
137
148
|
this.timer = undefined;
|
|
138
149
|
try {
|
|
150
|
+
await this.dispatchLoop.catch(() => undefined);
|
|
151
|
+
await Promise.all([...this.inflight]);
|
|
139
152
|
await this.tail;
|
|
140
153
|
}
|
|
141
154
|
finally {
|
|
@@ -314,33 +327,12 @@ export class CronAutomationManager {
|
|
|
314
327
|
}
|
|
315
328
|
async runOnceStatus(jobId) {
|
|
316
329
|
await this.start();
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
// Imported OpenClaw jobs refer to an OpenClaw agent id, not a DSH session.
|
|
324
|
-
// They must wait until mbh-chat supplies a verified DSH session binding.
|
|
325
|
-
if (!hasDshSession(job))
|
|
326
|
-
return 'delivery_unavailable';
|
|
327
|
-
const occurrenceAtMs = this.now();
|
|
328
|
-
job.state.runningAtMs = occurrenceAtMs;
|
|
329
|
-
job.updatedAtMs = occurrenceAtMs;
|
|
330
|
-
await this.repository.upsert([job]);
|
|
331
|
-
const delivered = await this.deliver(job, occurrenceAtMs);
|
|
332
|
-
delete job.state.runningAtMs;
|
|
333
|
-
job.state.lastRunAtMs = occurrenceAtMs;
|
|
334
|
-
job.state.lastRunStatus = delivered ? 'ok' : 'skipped';
|
|
335
|
-
job.updatedAtMs = this.now();
|
|
336
|
-
await this.repository.upsert([job]);
|
|
337
|
-
if (delivered) {
|
|
338
|
-
this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
339
|
-
return 'dispatched';
|
|
340
|
-
}
|
|
341
|
-
this.options.onChanged?.(cloneJob(job));
|
|
342
|
-
return 'delivery_unavailable';
|
|
343
|
-
});
|
|
330
|
+
const claimed = await this.enqueue(() => this.claimManualRun(jobId));
|
|
331
|
+
if (claimed.kind === 'status')
|
|
332
|
+
return claimed.status;
|
|
333
|
+
const delivered = await this.deliverTracked(claimed.job, claimed.occurrenceAtMs);
|
|
334
|
+
await this.enqueue(() => this.finalizeManualRun(claimed.job.id, claimed.occurrenceAtMs, delivered));
|
|
335
|
+
return delivered ? 'dispatched' : 'delivery_unavailable';
|
|
344
336
|
}
|
|
345
337
|
async runOnce(jobId) {
|
|
346
338
|
return (await this.runOnceStatus(jobId)) === 'dispatched';
|
|
@@ -354,10 +346,25 @@ export class CronAutomationManager {
|
|
|
354
346
|
.map(cloneJob);
|
|
355
347
|
}
|
|
356
348
|
notifyAgentAvailable(agentId) {
|
|
357
|
-
|
|
358
|
-
|
|
349
|
+
this.blockedAgents.delete(agentId);
|
|
350
|
+
this.blockedUntil.delete(agentId);
|
|
359
351
|
this.requestDispatch();
|
|
360
352
|
}
|
|
353
|
+
/** Reflect deletions only for ids recorded in this source's import ledger. */
|
|
354
|
+
async reconcileImportedDeletions(source, presentIds) {
|
|
355
|
+
await this.start();
|
|
356
|
+
return this.enqueue(async () => {
|
|
357
|
+
const removed = await this.repository.removeMissingImportedJobs(source, presentIds);
|
|
358
|
+
for (const id of removed) {
|
|
359
|
+
const job = this.jobs.get(id);
|
|
360
|
+
this.jobs.delete(id);
|
|
361
|
+
if (job)
|
|
362
|
+
this.options.onChanged?.(cloneJob(job));
|
|
363
|
+
}
|
|
364
|
+
this.arm();
|
|
365
|
+
return removed;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
361
368
|
/** Import each source job once. Existing ids win so DSH bindings stay intact. */
|
|
362
369
|
async importFrom(source, incoming) {
|
|
363
370
|
await this.start();
|
|
@@ -415,16 +422,129 @@ export class CronAutomationManager {
|
|
|
415
422
|
return false;
|
|
416
423
|
}
|
|
417
424
|
}
|
|
425
|
+
async deliverTracked(job, occurrenceAtMs) {
|
|
426
|
+
const work = this.deliver(job, occurrenceAtMs);
|
|
427
|
+
this.inflight.add(work);
|
|
428
|
+
try {
|
|
429
|
+
return await work;
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
this.inflight.delete(work);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async claimManualRun(jobId) {
|
|
436
|
+
if (this.stopping)
|
|
437
|
+
return { kind: 'status', status: 'delivery_unavailable' };
|
|
438
|
+
if (this.activeJobs.has(jobId))
|
|
439
|
+
return { kind: 'status', status: 'already_running' };
|
|
440
|
+
const job = this.jobs.get(jobId);
|
|
441
|
+
if (!job)
|
|
442
|
+
return { kind: 'status', status: 'not_found' };
|
|
443
|
+
if (job.state.runningAtMs !== undefined)
|
|
444
|
+
return { kind: 'status', status: 'already_running' };
|
|
445
|
+
// Imported OpenClaw jobs refer to an OpenClaw agent id, not a DSH session.
|
|
446
|
+
// They must wait until mbh-chat supplies a verified DSH session binding.
|
|
447
|
+
if (!hasDshSession(job))
|
|
448
|
+
return { kind: 'status', status: 'delivery_unavailable' };
|
|
449
|
+
const occurrenceAtMs = this.now();
|
|
450
|
+
this.activeJobs.add(jobId);
|
|
451
|
+
job.state.runningAtMs = occurrenceAtMs;
|
|
452
|
+
job.updatedAtMs = occurrenceAtMs;
|
|
453
|
+
try {
|
|
454
|
+
await this.repository.upsert([job]);
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
this.activeJobs.delete(jobId);
|
|
458
|
+
delete job.state.runningAtMs;
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
return { kind: 'ready', job: cloneJob(job), occurrenceAtMs };
|
|
462
|
+
}
|
|
463
|
+
async finalizeManualRun(jobId, occurrenceAtMs, delivered) {
|
|
464
|
+
this.activeJobs.delete(jobId);
|
|
465
|
+
const job = this.jobs.get(jobId);
|
|
466
|
+
if (!job)
|
|
467
|
+
return;
|
|
468
|
+
delete job.state.runningAtMs;
|
|
469
|
+
job.state.lastRunAtMs = occurrenceAtMs;
|
|
470
|
+
job.state.lastRunStatus = delivered ? 'ok' : 'skipped';
|
|
471
|
+
job.updatedAtMs = this.now();
|
|
472
|
+
await this.repository.upsert([job]);
|
|
473
|
+
if (delivered)
|
|
474
|
+
this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
475
|
+
else
|
|
476
|
+
this.options.onChanged?.(cloneJob(job));
|
|
477
|
+
}
|
|
478
|
+
async claimNextDueJob() {
|
|
479
|
+
if (this.stopping || this.schedulingPaused)
|
|
480
|
+
return undefined;
|
|
481
|
+
const now = this.now();
|
|
482
|
+
const job = [...this.jobs.values()]
|
|
483
|
+
.filter((candidate) => candidate.enabled &&
|
|
484
|
+
hasDshSession(candidate) &&
|
|
485
|
+
candidate.state.runningAtMs === undefined &&
|
|
486
|
+
!this.activeJobs.has(candidate.id) &&
|
|
487
|
+
candidate.state.nextRunAtMs !== undefined &&
|
|
488
|
+
candidate.state.nextRunAtMs <= now &&
|
|
489
|
+
(!this.blockedAgents.has(dshAgentIdFor(candidate)) || (this.blockedUntil.get(dshAgentIdFor(candidate)) ?? 0) <= now))
|
|
490
|
+
.sort((left, right) => (left.state.nextRunAtMs ?? 0) - (right.state.nextRunAtMs ?? 0) || left.createdAtMs - right.createdAtMs)[0];
|
|
491
|
+
if (!job)
|
|
492
|
+
return undefined;
|
|
493
|
+
const occurrenceAtMs = job.state.nextRunAtMs ?? now;
|
|
494
|
+
this.activeJobs.add(job.id);
|
|
495
|
+
job.state.runningAtMs = now;
|
|
496
|
+
job.updatedAtMs = now;
|
|
497
|
+
try {
|
|
498
|
+
await this.repository.upsert([job]);
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
this.activeJobs.delete(job.id);
|
|
502
|
+
delete job.state.runningAtMs;
|
|
503
|
+
throw error;
|
|
504
|
+
}
|
|
505
|
+
return { job: cloneJob(job), occurrenceAtMs };
|
|
506
|
+
}
|
|
507
|
+
async finalizeScheduledRun(jobId, occurrenceAtMs, delivered) {
|
|
508
|
+
this.activeJobs.delete(jobId);
|
|
509
|
+
const job = this.jobs.get(jobId);
|
|
510
|
+
if (!job)
|
|
511
|
+
return;
|
|
512
|
+
const now = this.now();
|
|
513
|
+
delete job.state.runningAtMs;
|
|
514
|
+
job.state.lastRunAtMs = now;
|
|
515
|
+
job.state.lastRunStatus = delivered ? 'ok' : 'skipped';
|
|
516
|
+
job.updatedAtMs = now;
|
|
517
|
+
if (!delivered) {
|
|
518
|
+
this.blockedAgents.add(dshAgentIdFor(job));
|
|
519
|
+
this.blockedUntil.set(dshAgentIdFor(job), now + DELIVERY_RETRY_DELAY_MS);
|
|
520
|
+
await this.repository.upsert([job]);
|
|
521
|
+
this.options.onChanged?.(cloneJob(job));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
this.blockedAgents.delete(dshAgentIdFor(job));
|
|
525
|
+
this.blockedUntil.delete(dshAgentIdFor(job));
|
|
526
|
+
if (job.deleteAfterRun === true || job.schedule.kind === 'at') {
|
|
527
|
+
this.jobs.delete(jobId);
|
|
528
|
+
await this.repository.delete(jobId);
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
if (job.enabled)
|
|
532
|
+
setNextOccurrence(job, nextOccurrence(now, job.schedule));
|
|
533
|
+
await this.repository.upsert([job]);
|
|
534
|
+
}
|
|
535
|
+
this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
536
|
+
}
|
|
418
537
|
enqueue(operation) {
|
|
419
538
|
const run = this.tail.then(operation);
|
|
420
539
|
this.tail = run.then(() => undefined, () => undefined);
|
|
421
540
|
return run;
|
|
422
541
|
}
|
|
423
542
|
requestDispatch() {
|
|
424
|
-
|
|
543
|
+
this.dispatchLoop = this.dispatchLoop.then(() => this.dispatchDue(), () => this.dispatchDue());
|
|
544
|
+
void this.dispatchLoop.catch((error) => this.options.onError?.(error));
|
|
425
545
|
}
|
|
426
546
|
arm() {
|
|
427
|
-
if (this.stopping)
|
|
547
|
+
if (this.stopping || this.schedulingPaused)
|
|
428
548
|
return;
|
|
429
549
|
if (this.timer)
|
|
430
550
|
clearTimeout(this.timer);
|
|
@@ -434,8 +554,11 @@ export class CronAutomationManager {
|
|
|
434
554
|
.filter((job) => job.enabled &&
|
|
435
555
|
hasDshSession(job) &&
|
|
436
556
|
job.state.runningAtMs === undefined &&
|
|
437
|
-
!this.blockedAgents.has(dshAgentIdFor(job)))
|
|
438
|
-
.map((job) =>
|
|
557
|
+
(!this.blockedAgents.has(dshAgentIdFor(job)) || (this.blockedUntil.get(dshAgentIdFor(job)) ?? 0) <= now))
|
|
558
|
+
.map((job) => {
|
|
559
|
+
const retryAt = this.blockedUntil.get(dshAgentIdFor(job));
|
|
560
|
+
return retryAt !== undefined && retryAt > now ? retryAt : job.state.nextRunAtMs;
|
|
561
|
+
})
|
|
439
562
|
.filter((value) => value !== undefined)
|
|
440
563
|
.reduce((earliest, candidate) => (earliest === undefined || candidate < earliest ? candidate : earliest), undefined);
|
|
441
564
|
if (target === undefined)
|
|
@@ -448,38 +571,17 @@ export class CronAutomationManager {
|
|
|
448
571
|
this.timer.unref();
|
|
449
572
|
}
|
|
450
573
|
async dispatchDue() {
|
|
451
|
-
if (this.stopping)
|
|
574
|
+
if (this.stopping || this.schedulingPaused)
|
|
452
575
|
return;
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
job.state.runningAtMs = now;
|
|
460
|
-
job.updatedAtMs = now;
|
|
461
|
-
await this.repository.upsert([job]);
|
|
462
|
-
const delivered = await this.deliver(job, occurrenceAtMs);
|
|
463
|
-
delete job.state.runningAtMs;
|
|
464
|
-
job.state.lastRunAtMs = now;
|
|
465
|
-
job.state.lastRunStatus = delivered ? 'ok' : 'skipped';
|
|
466
|
-
job.updatedAtMs = now;
|
|
467
|
-
if (!delivered) {
|
|
468
|
-
this.blockedAgents.add(dshAgentIdFor(job));
|
|
469
|
-
await this.repository.upsert([job]);
|
|
470
|
-
this.options.onChanged?.(cloneJob(job));
|
|
471
|
-
continue;
|
|
472
|
-
}
|
|
473
|
-
if (job.deleteAfterRun === true || job.schedule.kind === 'at') {
|
|
474
|
-
this.jobs.delete(job.id);
|
|
475
|
-
await this.repository.delete(job.id);
|
|
476
|
-
}
|
|
477
|
-
else {
|
|
478
|
-
setNextOccurrence(job, nextOccurrence(now, job.schedule));
|
|
479
|
-
await this.repository.upsert([job]);
|
|
480
|
-
}
|
|
481
|
-
this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
576
|
+
for (;;) {
|
|
577
|
+
const claimed = await this.enqueue(() => this.claimNextDueJob());
|
|
578
|
+
if (!claimed)
|
|
579
|
+
break;
|
|
580
|
+
const delivered = await this.deliverTracked(claimed.job, claimed.occurrenceAtMs);
|
|
581
|
+
await this.enqueue(() => this.finalizeScheduledRun(claimed.job.id, claimed.occurrenceAtMs, delivered));
|
|
482
582
|
}
|
|
483
|
-
this.
|
|
583
|
+
await this.enqueue(async () => {
|
|
584
|
+
this.arm();
|
|
585
|
+
});
|
|
484
586
|
}
|
|
485
587
|
}
|
package/src/openclaw-sqlite.d.ts
CHANGED
package/src/openclaw-sqlite.js
CHANGED
|
@@ -110,12 +110,12 @@ export function loadOpenClawSqliteJobs(sqlitePath) {
|
|
|
110
110
|
}
|
|
111
111
|
export function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
|
|
112
112
|
if (!existsSync(sqlitePath))
|
|
113
|
-
return { jobs: [], skipped: [] };
|
|
113
|
+
return { jobs: [], skipped: [], complete: false };
|
|
114
114
|
const database = new DatabaseSync(sqlitePath, { readOnly: true, timeout: 5_000 });
|
|
115
115
|
try {
|
|
116
116
|
const table = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_jobs'").get();
|
|
117
117
|
if (!table)
|
|
118
|
-
return { jobs: [], skipped: [] };
|
|
118
|
+
return { jobs: [], skipped: [], complete: false };
|
|
119
119
|
// SELECT * deliberately tolerates OpenClaw schema additions and older schemas
|
|
120
120
|
// that omit newer nullable projection columns. job_json remains authoritative.
|
|
121
121
|
const rows = database
|
|
@@ -139,7 +139,7 @@ export function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
|
|
|
139
139
|
});
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
return { jobs: [...jobs.values()], skipped };
|
|
142
|
+
return { jobs: [...jobs.values()], skipped, complete: skipped.length === 0 };
|
|
143
143
|
}
|
|
144
144
|
finally {
|
|
145
145
|
database.close();
|
package/src/repository.d.ts
CHANGED
|
@@ -83,6 +83,8 @@ export declare class CronAutomationRepository {
|
|
|
83
83
|
previewImport(source: string, incoming: readonly StoredCronAutomation[]): Promise<CronImportResult>;
|
|
84
84
|
import(source: string, incoming: readonly StoredCronAutomation[]): Promise<CronImportResult>;
|
|
85
85
|
listImportedJobs(): Promise<CronImportedJob[]>;
|
|
86
|
+
/** Only a verified complete source snapshot may authorize these deletions. */
|
|
87
|
+
removeMissingImportedJobs(source: string, presentIds: ReadonlySet<string>): Promise<string[]>;
|
|
86
88
|
migrateLegacy(migrationId: string, incoming: readonly StoredCronAutomation[], importedJobs: readonly CronImportedJob[]): Promise<CronLegacyMigrationResult>;
|
|
87
89
|
close(): void;
|
|
88
90
|
private planImport;
|
package/src/repository.js
CHANGED
|
@@ -344,6 +344,27 @@ export class CronAutomationRepository {
|
|
|
344
344
|
return [];
|
|
345
345
|
return this.database.prepare('SELECT source, job_id FROM cron_imported_jobs').all().map((row) => ({ source: row.source, jobId: row.job_id }));
|
|
346
346
|
}
|
|
347
|
+
/** Only a verified complete source snapshot may authorize these deletions. */
|
|
348
|
+
async removeMissingImportedJobs(source, presentIds) {
|
|
349
|
+
this.assertWritable();
|
|
350
|
+
if (!source.trim())
|
|
351
|
+
throw new Error('cron import source must be non-empty');
|
|
352
|
+
return this.writeTransaction(() => {
|
|
353
|
+
const rows = this.database
|
|
354
|
+
.prepare(`
|
|
355
|
+
SELECT jobs.job_id FROM cron_jobs AS jobs
|
|
356
|
+
INNER JOIN cron_imported_jobs AS imported ON imported.job_id = jobs.job_id
|
|
357
|
+
WHERE jobs.store_key = ? AND imported.source = ?
|
|
358
|
+
`)
|
|
359
|
+
.all(STORE_KEY, source);
|
|
360
|
+
const removed = rows.map((row) => row.job_id).filter((id) => !presentIds.has(id));
|
|
361
|
+
const statement = this.database.prepare('DELETE FROM cron_jobs WHERE store_key = ? AND job_id = ?');
|
|
362
|
+
for (const id of removed)
|
|
363
|
+
statement.run(STORE_KEY, id);
|
|
364
|
+
// Keep the import ledger: an old snapshot must not resurrect a deletion.
|
|
365
|
+
return removed;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
347
368
|
async migrateLegacy(migrationId, incoming, importedJobs) {
|
|
348
369
|
this.assertWritable();
|
|
349
370
|
const decoded = uniqueJobs(incoming);
|