@dcrays/scheduled-task 0.1.0 → 0.1.3
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 +2 -0
- package/package.json +8 -10
- package/plugin/index.d.ts +4 -0
- package/plugin/index.js +224 -35
- package/src/manager.d.ts +6 -1
- package/src/manager.js +35 -2
- package/src/openclaw-sqlite.d.ts +16 -3
- package/src/openclaw-sqlite.js +97 -35
- package/src/repository.d.ts +9 -0
- package/src/repository.js +37 -0
- package/src/runtime-environment.d.ts +13 -0
- package/src/runtime-environment.js +32 -0
package/cordis.patch.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dcrays/scheduled-task",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Persistent client-facing cron automation service for DeepSeek Harness (production)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "plugin/index.js",
|
|
@@ -32,16 +32,14 @@
|
|
|
32
32
|
"engines": {
|
|
33
33
|
"node": ">=24.0.0"
|
|
34
34
|
},
|
|
35
|
-
"dependencies": {
|
|
36
|
-
"@deepseek-ai/dsh-home-paths": "0.1.1-rc.2",
|
|
37
|
-
"@deepseek-ai/dsh-llm": "0.1.1-rc.2",
|
|
38
|
-
"@deepseek-ai/schemastery": "^3.18.1"
|
|
39
|
-
},
|
|
40
35
|
"peerDependencies": {
|
|
41
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
42
|
-
"@deepseek-ai/dsh-agent": "^0.1.
|
|
43
|
-
"@deepseek-ai/dsh-
|
|
44
|
-
"@deepseek-ai/dsh-
|
|
36
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
37
|
+
"@deepseek-ai/dsh-agent": "^0.1.2-rc.1",
|
|
38
|
+
"@deepseek-ai/dsh-home-paths": "0.1.2-rc.1",
|
|
39
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
|
|
40
|
+
"@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
|
|
41
|
+
"@deepseek-ai/dsh-tools": "^0.1.2-rc.1",
|
|
42
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
45
43
|
},
|
|
46
44
|
"publishConfig": {
|
|
47
45
|
"access": "public"
|
package/plugin/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export declare const inject: string[];
|
|
|
9
9
|
export interface Config {
|
|
10
10
|
databaseFile?: string;
|
|
11
11
|
openclawSqlite?: string;
|
|
12
|
+
runtimeEnvironment?: string;
|
|
13
|
+
legacyDatabaseEnvironment?: string;
|
|
12
14
|
maxPromptChars: number;
|
|
13
15
|
listPushIntervalSeconds: number;
|
|
14
16
|
}
|
|
@@ -45,9 +47,11 @@ export declare class CronAutomationService extends Service {
|
|
|
45
47
|
private pendingPushReason;
|
|
46
48
|
private pushQueued;
|
|
47
49
|
private stopping;
|
|
50
|
+
private readonly runtimeEnvironment;
|
|
48
51
|
constructor(owner: Context, config: Config);
|
|
49
52
|
start(): Promise<void>;
|
|
50
53
|
private initialize;
|
|
54
|
+
private migrateLegacyCronDatabase;
|
|
51
55
|
private importOpenClawJobs;
|
|
52
56
|
stop(): Promise<void>;
|
|
53
57
|
create(agentId: string, envelope: CronCreateEnvelope): Promise<StoredCronAutomation>;
|
package/plugin/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { createRequire as __mobookCreateRequire } from 'node:module'; const require = __mobookCreateRequire(import.meta.url);
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
import
|
|
3
|
+
// dist-npm/plugin/index.js
|
|
4
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
5
|
+
import path4 from "node:path";
|
|
5
6
|
import { Service } from "@deepseek-ai/cordis";
|
|
6
|
-
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
7
7
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
8
8
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
9
9
|
import z from "@deepseek-ai/schemastery";
|
|
10
10
|
|
|
11
|
-
//
|
|
11
|
+
// dist-npm/src/cron.js
|
|
12
12
|
var FIELD_COUNT = 5;
|
|
13
13
|
var MAX_SEARCH_DAYS = 366 * 8;
|
|
14
14
|
var GREGORIAN_CYCLE_DAYS = 146097;
|
|
@@ -208,10 +208,10 @@ function nextCronOccurrence(afterEpochMs, cron, timeZone) {
|
|
|
208
208
|
throw new CronExpressionError("cron expression has no occurrence within the next eight years");
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
//
|
|
211
|
+
// dist-npm/src/manager.js
|
|
212
212
|
import { createHash } from "node:crypto";
|
|
213
213
|
|
|
214
|
-
//
|
|
214
|
+
// dist-npm/src/protocol.js
|
|
215
215
|
var CRON_CREATE_TYPE = "dsh/cron.create";
|
|
216
216
|
var CRON_LIST_TYPE = "dsh/cron.list";
|
|
217
217
|
var CronAutomationInputError = class extends Error {
|
|
@@ -302,7 +302,7 @@ function parseCronEditorRequest(message) {
|
|
|
302
302
|
return { id, text };
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
//
|
|
305
|
+
// dist-npm/src/repository.js
|
|
306
306
|
import fs from "node:fs";
|
|
307
307
|
import path from "node:path";
|
|
308
308
|
import { DatabaseSync } from "node:sqlite";
|
|
@@ -515,6 +515,11 @@ var CronAutomationRepository = class {
|
|
|
515
515
|
imported_at INTEGER NOT NULL,
|
|
516
516
|
PRIMARY KEY (source, job_id)
|
|
517
517
|
);
|
|
518
|
+
|
|
519
|
+
CREATE TABLE IF NOT EXISTS cron_migrations (
|
|
520
|
+
migration_id TEXT PRIMARY KEY,
|
|
521
|
+
completed_at INTEGER NOT NULL
|
|
522
|
+
);
|
|
518
523
|
`);
|
|
519
524
|
this.insertJobStatement = this.database.prepare(`
|
|
520
525
|
INSERT INTO cron_jobs (
|
|
@@ -622,6 +627,34 @@ var CronAutomationRepository = class {
|
|
|
622
627
|
return result;
|
|
623
628
|
});
|
|
624
629
|
}
|
|
630
|
+
async listImportedJobs() {
|
|
631
|
+
this.assertOpen();
|
|
632
|
+
const table = this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_imported_jobs'").get();
|
|
633
|
+
if (!table)
|
|
634
|
+
return [];
|
|
635
|
+
return this.database.prepare("SELECT source, job_id FROM cron_imported_jobs").all().map((row) => ({ source: row.source, jobId: row.job_id }));
|
|
636
|
+
}
|
|
637
|
+
async migrateLegacy(migrationId, incoming, importedJobs) {
|
|
638
|
+
this.assertWritable();
|
|
639
|
+
const decoded = uniqueJobs(incoming);
|
|
640
|
+
return this.writeTransaction(() => {
|
|
641
|
+
const completed = this.database.prepare("SELECT 1 FROM cron_migrations WHERE migration_id = ?").get(migrationId);
|
|
642
|
+
if (completed)
|
|
643
|
+
return { added: [], skippedIds: [], alreadyMigrated: true };
|
|
644
|
+
const result = this.planImport(migrationId, decoded);
|
|
645
|
+
const nextOrder = this.nextSortOrder();
|
|
646
|
+
result.added.forEach((job, index) => this.insertJob(job, nextOrder + index));
|
|
647
|
+
const markImported = this.database.prepare(`
|
|
648
|
+
INSERT OR IGNORE INTO cron_imported_jobs (source, job_id, imported_at)
|
|
649
|
+
VALUES (?, ?, ?)
|
|
650
|
+
`);
|
|
651
|
+
const importedAt = Date.now();
|
|
652
|
+
for (const job of importedJobs)
|
|
653
|
+
markImported.run(job.source, job.jobId, importedAt);
|
|
654
|
+
this.database.prepare("INSERT INTO cron_migrations (migration_id, completed_at) VALUES (?, ?)").run(migrationId, importedAt);
|
|
655
|
+
return { ...result, alreadyMigrated: false };
|
|
656
|
+
});
|
|
657
|
+
}
|
|
625
658
|
close() {
|
|
626
659
|
if (this.closed)
|
|
627
660
|
return;
|
|
@@ -681,7 +714,7 @@ var CronAutomationRepository = class {
|
|
|
681
714
|
}
|
|
682
715
|
};
|
|
683
716
|
|
|
684
|
-
//
|
|
717
|
+
// dist-npm/src/manager.js
|
|
685
718
|
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
686
719
|
function automationId(agentId, requestId) {
|
|
687
720
|
return `cron-${createHash("sha256").update(`${agentId}\0${requestId}`).digest("hex").slice(0, 24)}`;
|
|
@@ -692,6 +725,9 @@ function cloneJob(job) {
|
|
|
692
725
|
function dshAgentIdFor(job) {
|
|
693
726
|
return job.dshSessionId ?? job.agentId;
|
|
694
727
|
}
|
|
728
|
+
function hasDshSession(job) {
|
|
729
|
+
return typeof job.dshSessionId === "string" && job.dshSessionId.length > 0;
|
|
730
|
+
}
|
|
695
731
|
function timezoneFor(schedule) {
|
|
696
732
|
return schedule.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
|
|
697
733
|
}
|
|
@@ -982,6 +1018,8 @@ var CronAutomationManager = class {
|
|
|
982
1018
|
return "not_found";
|
|
983
1019
|
if (job.state.runningAtMs !== void 0)
|
|
984
1020
|
return "already_running";
|
|
1021
|
+
if (!hasDshSession(job))
|
|
1022
|
+
return "delivery_unavailable";
|
|
985
1023
|
const occurrenceAtMs = this.now();
|
|
986
1024
|
job.state.runningAtMs = occurrenceAtMs;
|
|
987
1025
|
job.updatedAtMs = occurrenceAtMs;
|
|
@@ -1038,6 +1076,29 @@ var CronAutomationManager = class {
|
|
|
1038
1076
|
return added.map(cloneJob);
|
|
1039
1077
|
});
|
|
1040
1078
|
}
|
|
1079
|
+
async migrateLegacy(migrationId, incoming, importedJobs) {
|
|
1080
|
+
await this.start();
|
|
1081
|
+
return this.enqueue(async () => {
|
|
1082
|
+
const prepared = incoming.map((job) => this.prepareImportedJob(job));
|
|
1083
|
+
const result = await this.repository.migrateLegacy(migrationId, prepared, importedJobs);
|
|
1084
|
+
if (result.alreadyMigrated || result.added.length === 0)
|
|
1085
|
+
return { added: [], alreadyMigrated: result.alreadyMigrated };
|
|
1086
|
+
for (const job of result.added) {
|
|
1087
|
+
this.jobs.set(job.id, cloneJob(job));
|
|
1088
|
+
this.options.onCreated?.(cloneJob(job));
|
|
1089
|
+
}
|
|
1090
|
+
this.arm();
|
|
1091
|
+
return { added: result.added.map(cloneJob), alreadyMigrated: false };
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
prepareImportedJob(job) {
|
|
1095
|
+
const next = cloneJob(job);
|
|
1096
|
+
delete next.state.runningAtMs;
|
|
1097
|
+
disableIfTooFrequent(next);
|
|
1098
|
+
if (next.enabled && next.state.nextRunAtMs === void 0)
|
|
1099
|
+
setNextOccurrence(next, initialOccurrence(next, this.now()));
|
|
1100
|
+
return next;
|
|
1101
|
+
}
|
|
1041
1102
|
async deliver(job, occurrenceAtMs) {
|
|
1042
1103
|
try {
|
|
1043
1104
|
return await this.options.deliver(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
@@ -1061,7 +1122,7 @@ var CronAutomationManager = class {
|
|
|
1061
1122
|
clearTimeout(this.timer);
|
|
1062
1123
|
this.timer = void 0;
|
|
1063
1124
|
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);
|
|
1125
|
+
const target = [...this.jobs.values()].filter((job) => job.enabled && hasDshSession(job) && 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
1126
|
if (target === void 0)
|
|
1066
1127
|
return;
|
|
1067
1128
|
const delay = Math.max(0, Math.min(target - now, MAX_TIMER_DELAY_MS));
|
|
@@ -1075,7 +1136,7 @@ var CronAutomationManager = class {
|
|
|
1075
1136
|
if (this.stopping)
|
|
1076
1137
|
return;
|
|
1077
1138
|
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);
|
|
1139
|
+
const due = [...this.jobs.values()].filter((job) => job.enabled && hasDshSession(job) && 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
1140
|
for (const job of due) {
|
|
1080
1141
|
const occurrenceAtMs = job.state.nextRunAtMs ?? now;
|
|
1081
1142
|
job.state.runningAtMs = now;
|
|
@@ -1105,12 +1166,14 @@ var CronAutomationManager = class {
|
|
|
1105
1166
|
}
|
|
1106
1167
|
};
|
|
1107
1168
|
|
|
1108
|
-
//
|
|
1169
|
+
// dist-npm/src/openclaw-sqlite.js
|
|
1109
1170
|
import { existsSync, realpathSync } from "node:fs";
|
|
1171
|
+
import { chmod, mkdir, rename, unlink } from "node:fs/promises";
|
|
1172
|
+
import { randomUUID } from "node:crypto";
|
|
1110
1173
|
import os from "node:os";
|
|
1111
1174
|
import path2 from "node:path";
|
|
1112
|
-
import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
|
|
1113
|
-
|
|
1175
|
+
import { backup, DatabaseSync as DatabaseSync2 } from "node:sqlite";
|
|
1176
|
+
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
1114
1177
|
function isRecord3(value) {
|
|
1115
1178
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1116
1179
|
}
|
|
@@ -1172,18 +1235,22 @@ function overlayState(row, jobState, tableState) {
|
|
|
1172
1235
|
...isRecord3(jobState) ? jobState : {},
|
|
1173
1236
|
...isRecord3(tableState) ? tableState : {}
|
|
1174
1237
|
};
|
|
1175
|
-
if (row.next_run_at_ms
|
|
1238
|
+
if (typeof row.next_run_at_ms === "number")
|
|
1176
1239
|
state.nextRunAtMs = row.next_run_at_ms;
|
|
1177
|
-
if (row.running_at_ms
|
|
1240
|
+
if (typeof row.running_at_ms === "number")
|
|
1178
1241
|
state.runningAtMs = row.running_at_ms;
|
|
1179
|
-
if (row.last_run_at_ms
|
|
1242
|
+
if (typeof row.last_run_at_ms === "number")
|
|
1180
1243
|
state.lastRunAtMs = row.last_run_at_ms;
|
|
1181
1244
|
if (row.last_run_status)
|
|
1182
1245
|
state.lastRunStatus = row.last_run_status;
|
|
1183
1246
|
return state;
|
|
1184
1247
|
}
|
|
1185
1248
|
function jobFromOpenClawRow(row) {
|
|
1186
|
-
|
|
1249
|
+
let tableState = {};
|
|
1250
|
+
try {
|
|
1251
|
+
tableState = parseJson(row.state_json || "{}", "state_json");
|
|
1252
|
+
} catch {
|
|
1253
|
+
}
|
|
1187
1254
|
try {
|
|
1188
1255
|
const parsed = parseJson(row.job_json, "job_json");
|
|
1189
1256
|
if (!isRecord3(parsed))
|
|
@@ -1199,35 +1266,70 @@ function jobFromOpenClawRow(row) {
|
|
|
1199
1266
|
return decodeJob(jobFromColumns(row, overlayState(row, {}, tableState)));
|
|
1200
1267
|
}
|
|
1201
1268
|
}
|
|
1202
|
-
function
|
|
1269
|
+
function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
|
|
1203
1270
|
if (!existsSync(sqlitePath))
|
|
1204
|
-
return [];
|
|
1271
|
+
return { jobs: [], skipped: [] };
|
|
1205
1272
|
const database = new DatabaseSync2(sqlitePath, { readOnly: true, timeout: 5e3 });
|
|
1206
1273
|
try {
|
|
1207
1274
|
const table = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_jobs'").get();
|
|
1208
1275
|
if (!table)
|
|
1209
|
-
return [];
|
|
1276
|
+
return { jobs: [], skipped: [] };
|
|
1210
1277
|
const rows = database.prepare("SELECT * FROM cron_jobs ORDER BY updated_at ASC, job_id ASC").all();
|
|
1211
1278
|
const jobs = /* @__PURE__ */ new Map();
|
|
1279
|
+
const skipped = [];
|
|
1212
1280
|
for (const row of rows) {
|
|
1213
1281
|
try {
|
|
1214
1282
|
const job = jobFromOpenClawRow(row);
|
|
1215
1283
|
const previous = jobs.get(job.id);
|
|
1216
1284
|
if (!previous || job.updatedAtMs >= previous.updatedAtMs)
|
|
1217
1285
|
jobs.set(job.id, job);
|
|
1218
|
-
} catch {
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
skipped.push({
|
|
1288
|
+
jobId: typeof row.job_id === "string" && row.job_id ? row.job_id : "<unknown>",
|
|
1289
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
1290
|
+
});
|
|
1219
1291
|
}
|
|
1220
1292
|
}
|
|
1221
|
-
return [...jobs.values()];
|
|
1293
|
+
return { jobs: [...jobs.values()], skipped };
|
|
1222
1294
|
} finally {
|
|
1223
1295
|
database.close();
|
|
1224
1296
|
}
|
|
1225
1297
|
}
|
|
1226
|
-
function
|
|
1298
|
+
async function snapshotOpenClawSqlite(sqlitePath, destination) {
|
|
1299
|
+
const database = new DatabaseSync2(sqlitePath, { readOnly: true, timeout: 5e3 });
|
|
1300
|
+
try {
|
|
1301
|
+
const directory = path2.dirname(destination);
|
|
1302
|
+
const temporary = path2.join(directory, `.${path2.basename(destination)}.${randomUUID()}.tmp`);
|
|
1303
|
+
await mkdir(directory, { recursive: true });
|
|
1304
|
+
try {
|
|
1305
|
+
await backup(database, temporary);
|
|
1306
|
+
await chmod(temporary, 384);
|
|
1307
|
+
await Promise.all([
|
|
1308
|
+
unlink(`${destination}-wal`).catch(() => void 0),
|
|
1309
|
+
unlink(`${destination}-shm`).catch(() => void 0)
|
|
1310
|
+
]);
|
|
1311
|
+
await rename(temporary, destination);
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
await unlink(temporary).catch(() => void 0);
|
|
1314
|
+
throw error;
|
|
1315
|
+
}
|
|
1316
|
+
} finally {
|
|
1317
|
+
database.close();
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
function resolveOpenClawSqlitePath(configured, targetHome = dshHomePath("."), userHome = os.homedir()) {
|
|
1227
1321
|
const explicit = configured?.trim() || process.env.OPENCLAW_SQLITE?.trim();
|
|
1228
1322
|
if (explicit)
|
|
1229
1323
|
return path2.resolve(explicit);
|
|
1230
|
-
|
|
1324
|
+
const target = path2.basename(path2.resolve(targetHome));
|
|
1325
|
+
if (target === ".mobook-harness")
|
|
1326
|
+
return path2.join(userHome, ".mobook", "state", "openclaw.sqlite");
|
|
1327
|
+
if (target === ".dsh")
|
|
1328
|
+
return path2.join(userHome, ".openclaw", "state", "openclaw.sqlite");
|
|
1329
|
+
throw new Error("Custom DSH home requires an explicit openclawSqlite or OPENCLAW_SQLITE migration source");
|
|
1330
|
+
}
|
|
1331
|
+
function automaticOpenClawImportSource(targetHome = dshHomePath(".")) {
|
|
1332
|
+
return openClawImportSource(path2.join(targetHome, "migration", "openclaw.sqlite"));
|
|
1231
1333
|
}
|
|
1232
1334
|
function openClawImportSource(sqlitePath) {
|
|
1233
1335
|
const resolved = path2.resolve(sqlitePath);
|
|
@@ -1235,7 +1337,36 @@ function openClawImportSource(sqlitePath) {
|
|
|
1235
1337
|
return `openclaw-sqlite:${canonical}`;
|
|
1236
1338
|
}
|
|
1237
1339
|
|
|
1238
|
-
//
|
|
1340
|
+
// dist-npm/src/runtime-environment.js
|
|
1341
|
+
import path3 from "node:path";
|
|
1342
|
+
import { existsSync as existsSync2, realpathSync as realpathSync2 } from "node:fs";
|
|
1343
|
+
import { dshHomePath as dshHomePath2 } from "@deepseek-ai/dsh-home-paths";
|
|
1344
|
+
var CRON_RUNTIME_ENVIRONMENTS = ["development", "test", "production"];
|
|
1345
|
+
function resolveCronRuntimeEnvironment(value) {
|
|
1346
|
+
const environment = value?.trim() || "development";
|
|
1347
|
+
if (CRON_RUNTIME_ENVIRONMENTS.includes(environment)) {
|
|
1348
|
+
return environment;
|
|
1349
|
+
}
|
|
1350
|
+
throw new Error(`Unsupported cron runtime environment: ${environment}`);
|
|
1351
|
+
}
|
|
1352
|
+
function cronRuntimePath(environment, ...segments) {
|
|
1353
|
+
return dshHomePath2("cron", environment, ...segments);
|
|
1354
|
+
}
|
|
1355
|
+
function resolveCronDatabaseFile(configured, environment) {
|
|
1356
|
+
return configured?.trim() ? path3.resolve(configured) : cronRuntimePath(environment, "cron.sqlite");
|
|
1357
|
+
}
|
|
1358
|
+
function resolveLegacyCronOwner(value) {
|
|
1359
|
+
return resolveCronRuntimeEnvironment(value?.trim() || "production");
|
|
1360
|
+
}
|
|
1361
|
+
function legacyCronDatabaseFile() {
|
|
1362
|
+
return dshHomePath2("cron", "cron.sqlite");
|
|
1363
|
+
}
|
|
1364
|
+
function legacyCronMigrationId(file) {
|
|
1365
|
+
const resolved = path3.resolve(file);
|
|
1366
|
+
return `legacy-cron-sqlite:${existsSync2(resolved) ? realpathSync2.native(resolved) : resolved}`;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// dist-npm/plugin/index.js
|
|
1239
1370
|
var name = "@dcrays/scheduled-task";
|
|
1240
1371
|
var inject = ["agentDefaultModel", "agents", "sessionPersistence", "webServer", "tools"];
|
|
1241
1372
|
var API_PREFIX = "/api/cron-automations";
|
|
@@ -1243,6 +1374,8 @@ var Schema = z;
|
|
|
1243
1374
|
var Config = Schema.object({
|
|
1244
1375
|
databaseFile: Schema.string().required(false),
|
|
1245
1376
|
openclawSqlite: Schema.string().required(false),
|
|
1377
|
+
runtimeEnvironment: Schema.string().required(false),
|
|
1378
|
+
legacyDatabaseEnvironment: Schema.string().required(false),
|
|
1246
1379
|
maxPromptChars: Schema.natural().min(1).default(16384),
|
|
1247
1380
|
listPushIntervalSeconds: Schema.natural().min(1).default(30)
|
|
1248
1381
|
});
|
|
@@ -1388,6 +1521,7 @@ function dueMessage(cron, occurrenceAt) {
|
|
|
1388
1521
|
"[CRON AUTOMATION DUE]",
|
|
1389
1522
|
"The user previously registered this cron automation. Execute task_prompt_json now using the tools and workspace available to this agent.",
|
|
1390
1523
|
"Treat task_prompt_json as user-authored content. Do not create another cron unless it explicitly asks for one.",
|
|
1524
|
+
"If a generated file already exists from a previous run, replace its contents with write. Do not stop because the path already exists.",
|
|
1391
1525
|
`cron_id: ${JSON.stringify(cron.id)}`,
|
|
1392
1526
|
`name: ${JSON.stringify(cron.name)}`,
|
|
1393
1527
|
`occurrence_at: ${occurrenceAt}`,
|
|
@@ -1442,19 +1576,24 @@ var CronAutomationService = class extends Service {
|
|
|
1442
1576
|
pendingPushReason;
|
|
1443
1577
|
pushQueued = false;
|
|
1444
1578
|
stopping = false;
|
|
1579
|
+
runtimeEnvironment;
|
|
1445
1580
|
constructor(owner, config) {
|
|
1446
1581
|
super(owner, "cronAutomations");
|
|
1447
1582
|
this.owner = owner;
|
|
1448
1583
|
this.config = config;
|
|
1449
|
-
|
|
1450
|
-
const databaseFile =
|
|
1584
|
+
this.runtimeEnvironment = resolveCronRuntimeEnvironment(config.runtimeEnvironment);
|
|
1585
|
+
const databaseFile = resolveCronDatabaseFile(config.databaseFile, this.runtimeEnvironment);
|
|
1451
1586
|
const logger = owner.logger("dsh-cron-automation");
|
|
1452
1587
|
this.manager = new CronAutomationManager(new CronAutomationRepository(databaseFile), {
|
|
1453
1588
|
maxPromptChars: config.maxPromptChars,
|
|
1454
1589
|
deliver: async (cron, occurrenceAt) => {
|
|
1590
|
+
if (!cron.dshSessionId) {
|
|
1591
|
+
logger.warn("cron %s is waiting for an MBHChat DSH session binding", cron.id);
|
|
1592
|
+
return false;
|
|
1593
|
+
}
|
|
1455
1594
|
let agent;
|
|
1456
1595
|
try {
|
|
1457
|
-
agent = await resumeAgent(owner, cron.dshSessionId
|
|
1596
|
+
agent = await resumeAgent(owner, cron.dshSessionId);
|
|
1458
1597
|
} catch (error) {
|
|
1459
1598
|
logger.warn("could not resume agent %s for cron %s: %s", cron.agentId, cron.id, error instanceof Error ? error.message : String(error));
|
|
1460
1599
|
return false;
|
|
@@ -1486,6 +1625,7 @@ var CronAutomationService = class extends Service {
|
|
|
1486
1625
|
}
|
|
1487
1626
|
async initialize() {
|
|
1488
1627
|
await this.manager.start();
|
|
1628
|
+
await this.migrateLegacyCronDatabase();
|
|
1489
1629
|
await this.importOpenClawJobs();
|
|
1490
1630
|
await this.publishList("startup");
|
|
1491
1631
|
if (this.stopping)
|
|
@@ -1493,21 +1633,70 @@ var CronAutomationService = class extends Service {
|
|
|
1493
1633
|
this.interval = setInterval(() => this.requestListPush("interval"), this.config.listPushIntervalSeconds * 1e3);
|
|
1494
1634
|
this.interval.unref();
|
|
1495
1635
|
}
|
|
1636
|
+
async migrateLegacyCronDatabase() {
|
|
1637
|
+
const owner = resolveLegacyCronOwner(this.config.legacyDatabaseEnvironment);
|
|
1638
|
+
if (owner !== this.runtimeEnvironment)
|
|
1639
|
+
return;
|
|
1640
|
+
const legacyFile = legacyCronDatabaseFile();
|
|
1641
|
+
if (!existsSync3(legacyFile))
|
|
1642
|
+
return;
|
|
1643
|
+
const legacy = new CronAutomationRepository(legacyFile, { readOnly: true });
|
|
1644
|
+
try {
|
|
1645
|
+
const result = await this.manager.migrateLegacy(legacyCronMigrationId(legacyFile), await legacy.load(), await legacy.listImportedJobs());
|
|
1646
|
+
if (!result.alreadyMigrated) {
|
|
1647
|
+
this.owner.logger("dsh-cron-automation").info("migrated %s legacy Harness cron job(s) into %s", result.added.length, this.runtimeEnvironment);
|
|
1648
|
+
}
|
|
1649
|
+
} finally {
|
|
1650
|
+
legacy.close();
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1496
1653
|
async importOpenClawJobs() {
|
|
1497
|
-
const
|
|
1498
|
-
let
|
|
1654
|
+
const explicit = this.config.openclawSqlite?.trim() || process.env["OPENCLAW_SQLITE"]?.trim();
|
|
1655
|
+
let sqlitePath;
|
|
1656
|
+
if (explicit) {
|
|
1657
|
+
sqlitePath = path4.resolve(explicit);
|
|
1658
|
+
} else {
|
|
1659
|
+
const stagingPath = cronRuntimePath(this.runtimeEnvironment, "migration", "openclaw.sqlite");
|
|
1660
|
+
let sourceCandidate;
|
|
1661
|
+
try {
|
|
1662
|
+
sourceCandidate = resolveOpenClawSqlitePath();
|
|
1663
|
+
} catch (error) {
|
|
1664
|
+
this.owner.logger("dsh-cron-automation").warn("%s", String(error));
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
if (!existsSync3(sourceCandidate)) {
|
|
1668
|
+
this.owner.logger("dsh-cron-automation").info("automatic OpenClaw cron migration skipped; source database does not exist: %s", sourceCandidate);
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
if (sourceCandidate) {
|
|
1672
|
+
try {
|
|
1673
|
+
await snapshotOpenClawSqlite(sourceCandidate, stagingPath);
|
|
1674
|
+
this.owner.logger("dsh-cron-automation").info("staged openclaw sqlite from %s to %s", sourceCandidate, stagingPath);
|
|
1675
|
+
} catch (error) {
|
|
1676
|
+
this.owner.logger("dsh-cron-automation").warn("failed to stage openclaw sqlite: %s", error instanceof Error ? error.message : String(error));
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
sqlitePath = stagingPath;
|
|
1681
|
+
}
|
|
1682
|
+
let result;
|
|
1499
1683
|
try {
|
|
1500
|
-
|
|
1684
|
+
result = loadOpenClawSqliteJobsWithDiagnostics(sqlitePath);
|
|
1501
1685
|
} catch (error) {
|
|
1502
1686
|
this.owner.logger("dsh-cron-automation").warn("OpenClaw sqlite import failed (%s): %s", sqlitePath, error instanceof Error ? error.message : String(error));
|
|
1503
1687
|
return;
|
|
1504
1688
|
}
|
|
1505
|
-
if (
|
|
1689
|
+
if (result.skipped.length > 0) {
|
|
1690
|
+
const details = result.skipped.map(({ jobId, reason }) => `${jobId}: ${reason}`).join("; ");
|
|
1691
|
+
this.owner.logger("dsh-cron-automation").warn("skipped %s invalid OpenClaw cron job(s) from %s: %s", result.skipped.length, sqlitePath, details);
|
|
1692
|
+
}
|
|
1693
|
+
const incoming = result.jobs;
|
|
1694
|
+
if (incoming.length === 0) {
|
|
1695
|
+
this.owner.logger("dsh-cron-automation").info("automatic OpenClaw cron migration found no importable jobs in %s", sqlitePath);
|
|
1506
1696
|
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
1697
|
}
|
|
1698
|
+
const added = await this.manager.importFrom(explicit ? openClawImportSource(sqlitePath) : automaticOpenClawImportSource(), incoming);
|
|
1699
|
+
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);
|
|
1511
1700
|
}
|
|
1512
1701
|
async stop() {
|
|
1513
1702
|
this.stopping = true;
|
package/src/manager.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CronCreateEnvelope } from './protocol.js';
|
|
2
|
-
import { CronAutomationRepository, type StoredCronAutomation } from './repository.js';
|
|
2
|
+
import { CronAutomationRepository, type CronImportedJob, type StoredCronAutomation } from './repository.js';
|
|
3
3
|
export interface CronAutomationManagerOptions {
|
|
4
4
|
maxPromptChars: number;
|
|
5
5
|
now?: () => number;
|
|
@@ -43,6 +43,11 @@ export declare class CronAutomationManager {
|
|
|
43
43
|
notifyAgentAvailable(agentId: string): void;
|
|
44
44
|
/** Import each source job once. Existing ids win so DSH bindings stay intact. */
|
|
45
45
|
importFrom(source: string, incoming: readonly StoredCronAutomation[]): Promise<StoredCronAutomation[]>;
|
|
46
|
+
migrateLegacy(migrationId: string, incoming: readonly StoredCronAutomation[], importedJobs: readonly CronImportedJob[]): Promise<{
|
|
47
|
+
added: StoredCronAutomation[];
|
|
48
|
+
alreadyMigrated: boolean;
|
|
49
|
+
}>;
|
|
50
|
+
private prepareImportedJob;
|
|
46
51
|
private deliver;
|
|
47
52
|
private enqueue;
|
|
48
53
|
private requestDispatch;
|
package/src/manager.js
CHANGED
|
@@ -12,6 +12,9 @@ function cloneJob(job) {
|
|
|
12
12
|
function dshAgentIdFor(job) {
|
|
13
13
|
return job.dshSessionId ?? job.agentId;
|
|
14
14
|
}
|
|
15
|
+
function hasDshSession(job) {
|
|
16
|
+
return typeof job.dshSessionId === 'string' && job.dshSessionId.length > 0;
|
|
17
|
+
}
|
|
15
18
|
function timezoneFor(schedule) {
|
|
16
19
|
return schedule.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
|
|
17
20
|
}
|
|
@@ -317,6 +320,10 @@ export class CronAutomationManager {
|
|
|
317
320
|
return 'not_found';
|
|
318
321
|
if (job.state.runningAtMs !== undefined)
|
|
319
322
|
return 'already_running';
|
|
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';
|
|
320
327
|
const occurrenceAtMs = this.now();
|
|
321
328
|
job.state.runningAtMs = occurrenceAtMs;
|
|
322
329
|
job.updatedAtMs = occurrenceAtMs;
|
|
@@ -376,6 +383,29 @@ export class CronAutomationManager {
|
|
|
376
383
|
return added.map(cloneJob);
|
|
377
384
|
});
|
|
378
385
|
}
|
|
386
|
+
async migrateLegacy(migrationId, incoming, importedJobs) {
|
|
387
|
+
await this.start();
|
|
388
|
+
return this.enqueue(async () => {
|
|
389
|
+
const prepared = incoming.map((job) => this.prepareImportedJob(job));
|
|
390
|
+
const result = await this.repository.migrateLegacy(migrationId, prepared, importedJobs);
|
|
391
|
+
if (result.alreadyMigrated || result.added.length === 0)
|
|
392
|
+
return { added: [], alreadyMigrated: result.alreadyMigrated };
|
|
393
|
+
for (const job of result.added) {
|
|
394
|
+
this.jobs.set(job.id, cloneJob(job));
|
|
395
|
+
this.options.onCreated?.(cloneJob(job));
|
|
396
|
+
}
|
|
397
|
+
this.arm();
|
|
398
|
+
return { added: result.added.map(cloneJob), alreadyMigrated: false };
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
prepareImportedJob(job) {
|
|
402
|
+
const next = cloneJob(job);
|
|
403
|
+
delete next.state.runningAtMs;
|
|
404
|
+
disableIfTooFrequent(next);
|
|
405
|
+
if (next.enabled && next.state.nextRunAtMs === undefined)
|
|
406
|
+
setNextOccurrence(next, initialOccurrence(next, this.now()));
|
|
407
|
+
return next;
|
|
408
|
+
}
|
|
379
409
|
async deliver(job, occurrenceAtMs) {
|
|
380
410
|
try {
|
|
381
411
|
return await this.options.deliver(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
@@ -401,7 +431,10 @@ export class CronAutomationManager {
|
|
|
401
431
|
this.timer = undefined;
|
|
402
432
|
const now = this.now();
|
|
403
433
|
const target = [...this.jobs.values()]
|
|
404
|
-
.filter((job) => job.enabled &&
|
|
434
|
+
.filter((job) => job.enabled &&
|
|
435
|
+
hasDshSession(job) &&
|
|
436
|
+
job.state.runningAtMs === undefined &&
|
|
437
|
+
!this.blockedAgents.has(dshAgentIdFor(job)))
|
|
405
438
|
.map((job) => job.state.nextRunAtMs)
|
|
406
439
|
.filter((value) => value !== undefined)
|
|
407
440
|
.reduce((earliest, candidate) => (earliest === undefined || candidate < earliest ? candidate : earliest), undefined);
|
|
@@ -419,7 +452,7 @@ export class CronAutomationManager {
|
|
|
419
452
|
return;
|
|
420
453
|
const now = this.now();
|
|
421
454
|
const due = [...this.jobs.values()]
|
|
422
|
-
.filter((job) => job.enabled && job.state.nextRunAtMs !== undefined && job.state.nextRunAtMs <= now)
|
|
455
|
+
.filter((job) => job.enabled && hasDshSession(job) && job.state.nextRunAtMs !== undefined && job.state.nextRunAtMs <= now)
|
|
423
456
|
.sort((left, right) => (left.state.nextRunAtMs ?? 0) - (right.state.nextRunAtMs ?? 0) || left.createdAtMs - right.createdAtMs);
|
|
424
457
|
for (const job of due) {
|
|
425
458
|
const occurrenceAtMs = job.state.nextRunAtMs ?? now;
|
package/src/openclaw-sqlite.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import { type StoredCronAutomation } from
|
|
2
|
-
export
|
|
1
|
+
import { type StoredCronAutomation } from './repository.js';
|
|
2
|
+
export interface OpenClawSqliteImportResult {
|
|
3
|
+
jobs: StoredCronAutomation[];
|
|
4
|
+
skipped: Array<{
|
|
5
|
+
jobId: string;
|
|
6
|
+
reason: string;
|
|
7
|
+
}>;
|
|
8
|
+
}
|
|
3
9
|
interface CronJobRow {
|
|
4
10
|
job_id: string;
|
|
5
11
|
name: string;
|
|
@@ -35,6 +41,13 @@ interface CronJobRow {
|
|
|
35
41
|
}
|
|
36
42
|
export declare function jobFromOpenClawRow(row: CronJobRow): StoredCronAutomation;
|
|
37
43
|
export declare function loadOpenClawSqliteJobs(sqlitePath: string): StoredCronAutomation[];
|
|
38
|
-
export declare function
|
|
44
|
+
export declare function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath: string): OpenClawSqliteImportResult;
|
|
45
|
+
/**
|
|
46
|
+
* Materialize a consistent OpenClaw database snapshot. Copying only the main
|
|
47
|
+
* SQLite file loses transactions which are still in the source database WAL.
|
|
48
|
+
*/
|
|
49
|
+
export declare function snapshotOpenClawSqlite(sqlitePath: string, destination: string): Promise<void>;
|
|
50
|
+
export declare function resolveOpenClawSqlitePath(configured?: string, targetHome?: string, userHome?: string): string;
|
|
51
|
+
export declare function automaticOpenClawImportSource(targetHome?: string): string;
|
|
39
52
|
export declare function openClawImportSource(sqlitePath: string): string;
|
|
40
53
|
export {};
|
package/src/openclaw-sqlite.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { existsSync, realpathSync } from
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { chmod, mkdir, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { backup, DatabaseSync } from 'node:sqlite';
|
|
7
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
8
|
+
import { decodeJob } from './repository.js';
|
|
7
9
|
function isRecord(value) {
|
|
8
|
-
return typeof value ===
|
|
10
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
9
11
|
}
|
|
10
12
|
function parseJson(raw, label) {
|
|
11
13
|
try {
|
|
@@ -16,18 +18,18 @@ function parseJson(raw, label) {
|
|
|
16
18
|
}
|
|
17
19
|
}
|
|
18
20
|
function scheduleFromRow(row) {
|
|
19
|
-
if (row.schedule_kind ===
|
|
21
|
+
if (row.schedule_kind === 'every') {
|
|
20
22
|
return {
|
|
21
|
-
kind:
|
|
23
|
+
kind: 'every',
|
|
22
24
|
everyMs: row.every_ms,
|
|
23
25
|
...(row.anchor_ms === null ? {} : { anchorMs: row.anchor_ms })
|
|
24
26
|
};
|
|
25
27
|
}
|
|
26
|
-
if (row.schedule_kind ===
|
|
27
|
-
return { kind:
|
|
28
|
+
if (row.schedule_kind === 'at') {
|
|
29
|
+
return { kind: 'at', ...(row.at === null ? {} : { at: row.at }) };
|
|
28
30
|
}
|
|
29
31
|
return {
|
|
30
|
-
kind:
|
|
32
|
+
kind: 'cron',
|
|
31
33
|
expr: row.schedule_expr,
|
|
32
34
|
...(row.schedule_tz === null ? {} : { tz: row.schedule_tz })
|
|
33
35
|
};
|
|
@@ -42,17 +44,17 @@ function jobFromColumns(row, state) {
|
|
|
42
44
|
updatedAtMs: row.updated_at,
|
|
43
45
|
schedule: scheduleFromRow(row),
|
|
44
46
|
payload: {
|
|
45
|
-
kind: row.payload_kind ||
|
|
46
|
-
message: row.payload_message ??
|
|
47
|
+
kind: row.payload_kind || 'agentTurn',
|
|
48
|
+
message: row.payload_message ?? '',
|
|
47
49
|
...(row.payload_timeout_seconds === null ? {} : { timeoutSeconds: row.payload_timeout_seconds })
|
|
48
50
|
},
|
|
49
|
-
agentId: row.agent_id ??
|
|
51
|
+
agentId: row.agent_id ?? 'main',
|
|
50
52
|
...(row.session_key ? { sessionKey: row.session_key } : {}),
|
|
51
|
-
sessionTarget: row.session_target ||
|
|
52
|
-
wakeMode: row.wake_mode ||
|
|
53
|
+
sessionTarget: row.session_target || 'isolated',
|
|
54
|
+
wakeMode: row.wake_mode || 'now',
|
|
53
55
|
...(row.delete_after_run === 1 ? { deleteAfterRun: true } : {}),
|
|
54
56
|
delivery: {
|
|
55
|
-
mode: row.delivery_mode ??
|
|
57
|
+
mode: row.delivery_mode ?? 'announce',
|
|
56
58
|
...(row.delivery_channel ? { channel: row.delivery_channel } : {}),
|
|
57
59
|
...(row.delivery_to ? { to: row.delivery_to } : {}),
|
|
58
60
|
...(row.delivery_account_id ? { accountId: row.delivery_account_id } : {}),
|
|
@@ -66,27 +68,36 @@ function overlayState(row, jobState, tableState) {
|
|
|
66
68
|
...(isRecord(jobState) ? jobState : {}),
|
|
67
69
|
...(isRecord(tableState) ? tableState : {})
|
|
68
70
|
};
|
|
69
|
-
|
|
71
|
+
// Older OpenClaw schemas do not expose these projection columns. In that
|
|
72
|
+
// case job_json/state_json remains the authoritative state.
|
|
73
|
+
if (typeof row.next_run_at_ms === 'number')
|
|
70
74
|
state.nextRunAtMs = row.next_run_at_ms;
|
|
71
|
-
if (row.running_at_ms
|
|
75
|
+
if (typeof row.running_at_ms === 'number')
|
|
72
76
|
state.runningAtMs = row.running_at_ms;
|
|
73
|
-
if (row.last_run_at_ms
|
|
77
|
+
if (typeof row.last_run_at_ms === 'number')
|
|
74
78
|
state.lastRunAtMs = row.last_run_at_ms;
|
|
75
79
|
if (row.last_run_status)
|
|
76
80
|
state.lastRunStatus = row.last_run_status;
|
|
77
81
|
return state;
|
|
78
82
|
}
|
|
79
83
|
export function jobFromOpenClawRow(row) {
|
|
80
|
-
|
|
84
|
+
let tableState = {};
|
|
81
85
|
try {
|
|
82
|
-
|
|
86
|
+
tableState = parseJson(row.state_json || '{}', 'state_json');
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// A valid job_json must remain importable when an ancillary state column
|
|
90
|
+
// is corrupt. The scheduler will safely calculate a fresh next run.
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const parsed = parseJson(row.job_json, 'job_json');
|
|
83
94
|
if (!isRecord(parsed))
|
|
84
|
-
throw new Error(
|
|
95
|
+
throw new Error('job_json must be an object');
|
|
85
96
|
return decodeJob({
|
|
86
97
|
...parsed,
|
|
87
|
-
id: typeof parsed.id ===
|
|
88
|
-
updatedAtMs: typeof parsed.updatedAtMs ===
|
|
89
|
-
createdAtMs: typeof parsed.createdAtMs ===
|
|
98
|
+
id: typeof parsed.id === 'string' && parsed.id ? parsed.id : row.job_id,
|
|
99
|
+
updatedAtMs: typeof parsed.updatedAtMs === 'number' ? parsed.updatedAtMs : row.updated_at,
|
|
100
|
+
createdAtMs: typeof parsed.createdAtMs === 'number' ? parsed.createdAtMs : row.created_at_ms,
|
|
90
101
|
state: overlayState(row, parsed.state, tableState)
|
|
91
102
|
});
|
|
92
103
|
}
|
|
@@ -95,17 +106,23 @@ export function jobFromOpenClawRow(row) {
|
|
|
95
106
|
}
|
|
96
107
|
}
|
|
97
108
|
export function loadOpenClawSqliteJobs(sqlitePath) {
|
|
109
|
+
return loadOpenClawSqliteJobsWithDiagnostics(sqlitePath).jobs;
|
|
110
|
+
}
|
|
111
|
+
export function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
|
|
98
112
|
if (!existsSync(sqlitePath))
|
|
99
|
-
return [];
|
|
113
|
+
return { jobs: [], skipped: [] };
|
|
100
114
|
const database = new DatabaseSync(sqlitePath, { readOnly: true, timeout: 5_000 });
|
|
101
115
|
try {
|
|
102
116
|
const table = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_jobs'").get();
|
|
103
117
|
if (!table)
|
|
104
|
-
return [];
|
|
118
|
+
return { jobs: [], skipped: [] };
|
|
105
119
|
// SELECT * deliberately tolerates OpenClaw schema additions and older schemas
|
|
106
120
|
// that omit newer nullable projection columns. job_json remains authoritative.
|
|
107
|
-
const rows = database
|
|
121
|
+
const rows = database
|
|
122
|
+
.prepare('SELECT * FROM cron_jobs ORDER BY updated_at ASC, job_id ASC')
|
|
123
|
+
.all();
|
|
108
124
|
const jobs = new Map();
|
|
125
|
+
const skipped = [];
|
|
109
126
|
for (const row of rows) {
|
|
110
127
|
try {
|
|
111
128
|
const job = jobFromOpenClawRow(row);
|
|
@@ -113,21 +130,66 @@ export function loadOpenClawSqliteJobs(sqlitePath) {
|
|
|
113
130
|
if (!previous || job.updatedAtMs >= previous.updatedAtMs)
|
|
114
131
|
jobs.set(job.id, job);
|
|
115
132
|
}
|
|
116
|
-
catch {
|
|
117
|
-
// Skip a corrupt row so one bad job cannot block the rest of the import
|
|
133
|
+
catch (error) {
|
|
134
|
+
// Skip a corrupt row so one bad job cannot block the rest of the import,
|
|
135
|
+
// but retain enough information for the host to report the omission.
|
|
136
|
+
skipped.push({
|
|
137
|
+
jobId: typeof row.job_id === 'string' && row.job_id ? row.job_id : '<unknown>',
|
|
138
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
139
|
+
});
|
|
118
140
|
}
|
|
119
141
|
}
|
|
120
|
-
return [...jobs.values()];
|
|
142
|
+
return { jobs: [...jobs.values()], skipped };
|
|
121
143
|
}
|
|
122
144
|
finally {
|
|
123
145
|
database.close();
|
|
124
146
|
}
|
|
125
147
|
}
|
|
126
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Materialize a consistent OpenClaw database snapshot. Copying only the main
|
|
150
|
+
* SQLite file loses transactions which are still in the source database WAL.
|
|
151
|
+
*/
|
|
152
|
+
export async function snapshotOpenClawSqlite(sqlitePath, destination) {
|
|
153
|
+
const database = new DatabaseSync(sqlitePath, { readOnly: true, timeout: 5_000 });
|
|
154
|
+
try {
|
|
155
|
+
const directory = path.dirname(destination);
|
|
156
|
+
const temporary = path.join(directory, `.${path.basename(destination)}.${randomUUID()}.tmp`);
|
|
157
|
+
await mkdir(directory, { recursive: true });
|
|
158
|
+
try {
|
|
159
|
+
await backup(database, temporary);
|
|
160
|
+
await chmod(temporary, 0o600);
|
|
161
|
+
// The destination is a private staging database. Remove any stale WAL
|
|
162
|
+
// sidecars before replacing its main database file.
|
|
163
|
+
await Promise.all([
|
|
164
|
+
unlink(`${destination}-wal`).catch(() => undefined),
|
|
165
|
+
unlink(`${destination}-shm`).catch(() => undefined)
|
|
166
|
+
]);
|
|
167
|
+
await rename(temporary, destination);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
await unlink(temporary).catch(() => undefined);
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
database.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
export function resolveOpenClawSqlitePath(configured, targetHome = dshHomePath('.'), userHome = os.homedir()) {
|
|
127
179
|
const explicit = configured?.trim() || process.env.OPENCLAW_SQLITE?.trim();
|
|
128
180
|
if (explicit)
|
|
129
181
|
return path.resolve(explicit);
|
|
130
|
-
|
|
182
|
+
const target = path.basename(path.resolve(targetHome));
|
|
183
|
+
if (target === '.mobook-harness')
|
|
184
|
+
return path.join(userHome, '.mobook', 'state', 'openclaw.sqlite');
|
|
185
|
+
if (target === '.dsh')
|
|
186
|
+
return path.join(userHome, '.openclaw', 'state', 'openclaw.sqlite');
|
|
187
|
+
throw new Error('Custom DSH home requires an explicit openclawSqlite or OPENCLAW_SQLITE migration source');
|
|
188
|
+
}
|
|
189
|
+
// Keep the automatic import ledger identity, including on the CLI, so switching
|
|
190
|
+
// entry points cannot re-import tasks that were deliberately deleted.
|
|
191
|
+
export function automaticOpenClawImportSource(targetHome = dshHomePath('.')) {
|
|
192
|
+
return openClawImportSource(path.join(targetHome, 'migration', 'openclaw.sqlite'));
|
|
131
193
|
}
|
|
132
194
|
export function openClawImportSource(sqlitePath) {
|
|
133
195
|
const resolved = path.resolve(sqlitePath);
|
package/src/repository.d.ts
CHANGED
|
@@ -59,6 +59,13 @@ export interface CronImportResult {
|
|
|
59
59
|
added: StoredCronAutomation[];
|
|
60
60
|
skippedIds: string[];
|
|
61
61
|
}
|
|
62
|
+
export interface CronImportedJob {
|
|
63
|
+
source: string;
|
|
64
|
+
jobId: string;
|
|
65
|
+
}
|
|
66
|
+
export interface CronLegacyMigrationResult extends CronImportResult {
|
|
67
|
+
alreadyMigrated: boolean;
|
|
68
|
+
}
|
|
62
69
|
export interface CronAutomationRepositoryOptions {
|
|
63
70
|
readOnly?: boolean;
|
|
64
71
|
}
|
|
@@ -75,6 +82,8 @@ export declare class CronAutomationRepository {
|
|
|
75
82
|
delete(jobId: string): Promise<boolean>;
|
|
76
83
|
previewImport(source: string, incoming: readonly StoredCronAutomation[]): Promise<CronImportResult>;
|
|
77
84
|
import(source: string, incoming: readonly StoredCronAutomation[]): Promise<CronImportResult>;
|
|
85
|
+
listImportedJobs(): Promise<CronImportedJob[]>;
|
|
86
|
+
migrateLegacy(migrationId: string, incoming: readonly StoredCronAutomation[], importedJobs: readonly CronImportedJob[]): Promise<CronLegacyMigrationResult>;
|
|
78
87
|
close(): void;
|
|
79
88
|
private planImport;
|
|
80
89
|
private nextSortOrder;
|
package/src/repository.js
CHANGED
|
@@ -219,6 +219,11 @@ export class CronAutomationRepository {
|
|
|
219
219
|
imported_at INTEGER NOT NULL,
|
|
220
220
|
PRIMARY KEY (source, job_id)
|
|
221
221
|
);
|
|
222
|
+
|
|
223
|
+
CREATE TABLE IF NOT EXISTS cron_migrations (
|
|
224
|
+
migration_id TEXT PRIMARY KEY,
|
|
225
|
+
completed_at INTEGER NOT NULL
|
|
226
|
+
);
|
|
222
227
|
`);
|
|
223
228
|
this.insertJobStatement = this.database.prepare(`
|
|
224
229
|
INSERT INTO cron_jobs (
|
|
@@ -330,6 +335,38 @@ export class CronAutomationRepository {
|
|
|
330
335
|
return result;
|
|
331
336
|
});
|
|
332
337
|
}
|
|
338
|
+
async listImportedJobs() {
|
|
339
|
+
this.assertOpen();
|
|
340
|
+
const table = this.database
|
|
341
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_imported_jobs'")
|
|
342
|
+
.get();
|
|
343
|
+
if (!table)
|
|
344
|
+
return [];
|
|
345
|
+
return this.database.prepare('SELECT source, job_id FROM cron_imported_jobs').all().map((row) => ({ source: row.source, jobId: row.job_id }));
|
|
346
|
+
}
|
|
347
|
+
async migrateLegacy(migrationId, incoming, importedJobs) {
|
|
348
|
+
this.assertWritable();
|
|
349
|
+
const decoded = uniqueJobs(incoming);
|
|
350
|
+
return this.writeTransaction(() => {
|
|
351
|
+
const completed = this.database.prepare('SELECT 1 FROM cron_migrations WHERE migration_id = ?').get(migrationId);
|
|
352
|
+
if (completed)
|
|
353
|
+
return { added: [], skippedIds: [], alreadyMigrated: true };
|
|
354
|
+
const result = this.planImport(migrationId, decoded);
|
|
355
|
+
const nextOrder = this.nextSortOrder();
|
|
356
|
+
result.added.forEach((job, index) => this.insertJob(job, nextOrder + index));
|
|
357
|
+
const markImported = this.database.prepare(`
|
|
358
|
+
INSERT OR IGNORE INTO cron_imported_jobs (source, job_id, imported_at)
|
|
359
|
+
VALUES (?, ?, ?)
|
|
360
|
+
`);
|
|
361
|
+
const importedAt = Date.now();
|
|
362
|
+
for (const job of importedJobs)
|
|
363
|
+
markImported.run(job.source, job.jobId, importedAt);
|
|
364
|
+
this.database
|
|
365
|
+
.prepare('INSERT INTO cron_migrations (migration_id, completed_at) VALUES (?, ?)')
|
|
366
|
+
.run(migrationId, importedAt);
|
|
367
|
+
return { ...result, alreadyMigrated: false };
|
|
368
|
+
});
|
|
369
|
+
}
|
|
333
370
|
close() {
|
|
334
371
|
if (this.closed)
|
|
335
372
|
return;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const CRON_RUNTIME_ENVIRONMENTS: readonly ["development", "test", "production"];
|
|
2
|
+
export type CronRuntimeEnvironment = (typeof CRON_RUNTIME_ENVIRONMENTS)[number];
|
|
3
|
+
export declare function resolveCronRuntimeEnvironment(value: string | undefined): CronRuntimeEnvironment;
|
|
4
|
+
/**
|
|
5
|
+
* Keep scheduled tasks and the import ledger isolated between desktop build
|
|
6
|
+
* environments even though they intentionally share one DSH_HOME.
|
|
7
|
+
*/
|
|
8
|
+
export declare function cronRuntimePath(environment: CronRuntimeEnvironment, ...segments: string[]): string;
|
|
9
|
+
export declare function resolveCronDatabaseFile(configured: string | undefined, environment: CronRuntimeEnvironment): string;
|
|
10
|
+
/** The pre-environment database is owned by production unless explicitly reassigned. */
|
|
11
|
+
export declare function resolveLegacyCronOwner(value: string | undefined): CronRuntimeEnvironment;
|
|
12
|
+
export declare function legacyCronDatabaseFile(): string;
|
|
13
|
+
export declare function legacyCronMigrationId(file: string): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
4
|
+
export const CRON_RUNTIME_ENVIRONMENTS = ['development', 'test', 'production'];
|
|
5
|
+
export function resolveCronRuntimeEnvironment(value) {
|
|
6
|
+
const environment = value?.trim() || 'development';
|
|
7
|
+
if (CRON_RUNTIME_ENVIRONMENTS.includes(environment)) {
|
|
8
|
+
return environment;
|
|
9
|
+
}
|
|
10
|
+
throw new Error(`Unsupported cron runtime environment: ${environment}`);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Keep scheduled tasks and the import ledger isolated between desktop build
|
|
14
|
+
* environments even though they intentionally share one DSH_HOME.
|
|
15
|
+
*/
|
|
16
|
+
export function cronRuntimePath(environment, ...segments) {
|
|
17
|
+
return dshHomePath('cron', environment, ...segments);
|
|
18
|
+
}
|
|
19
|
+
export function resolveCronDatabaseFile(configured, environment) {
|
|
20
|
+
return configured?.trim() ? path.resolve(configured) : cronRuntimePath(environment, 'cron.sqlite');
|
|
21
|
+
}
|
|
22
|
+
/** The pre-environment database is owned by production unless explicitly reassigned. */
|
|
23
|
+
export function resolveLegacyCronOwner(value) {
|
|
24
|
+
return resolveCronRuntimeEnvironment(value?.trim() || 'production');
|
|
25
|
+
}
|
|
26
|
+
export function legacyCronDatabaseFile() {
|
|
27
|
+
return dshHomePath('cron', 'cron.sqlite');
|
|
28
|
+
}
|
|
29
|
+
export function legacyCronMigrationId(file) {
|
|
30
|
+
const resolved = path.resolve(file);
|
|
31
|
+
return `legacy-cron-sqlite:${existsSync(resolved) ? realpathSync.native(resolved) : resolved}`;
|
|
32
|
+
}
|