@dcrays/scheduled-task 0.1.1 → 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 CHANGED
@@ -8,5 +8,7 @@
8
8
  - webServer
9
9
  - tools
10
10
  config:
11
+ runtimeEnvironment: production
12
+ legacyDatabaseEnvironment: production
11
13
  maxPromptChars: 16384
12
14
  listPushIntervalSeconds: 30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcrays/scheduled-task",
3
- "version": "0.1.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",
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,10 +1,9 @@
1
1
  import { createRequire as __mobookCreateRequire } from 'node:module'; const require = __mobookCreateRequire(import.meta.url);
2
2
 
3
3
  // dist-npm/plugin/index.js
4
- import { existsSync as existsSync2 } from "node:fs";
5
- import path3 from "node:path";
4
+ import { existsSync as existsSync3 } from "node:fs";
5
+ import path4 from "node:path";
6
6
  import { Service } from "@deepseek-ai/cordis";
7
- import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
8
7
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
9
8
  import { defineTool } from "@deepseek-ai/dsh-tools";
10
9
  import z from "@deepseek-ai/schemastery";
@@ -516,6 +515,11 @@ var CronAutomationRepository = class {
516
515
  imported_at INTEGER NOT NULL,
517
516
  PRIMARY KEY (source, job_id)
518
517
  );
518
+
519
+ CREATE TABLE IF NOT EXISTS cron_migrations (
520
+ migration_id TEXT PRIMARY KEY,
521
+ completed_at INTEGER NOT NULL
522
+ );
519
523
  `);
520
524
  this.insertJobStatement = this.database.prepare(`
521
525
  INSERT INTO cron_jobs (
@@ -623,6 +627,34 @@ var CronAutomationRepository = class {
623
627
  return result;
624
628
  });
625
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
+ }
626
658
  close() {
627
659
  if (this.closed)
628
660
  return;
@@ -1044,6 +1076,29 @@ var CronAutomationManager = class {
1044
1076
  return added.map(cloneJob);
1045
1077
  });
1046
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
+ }
1047
1102
  async deliver(job, occurrenceAtMs) {
1048
1103
  try {
1049
1104
  return await this.options.deliver(cloneJob(job), new Date(occurrenceAtMs).toISOString());
@@ -1113,13 +1168,12 @@ var CronAutomationManager = class {
1113
1168
 
1114
1169
  // dist-npm/src/openclaw-sqlite.js
1115
1170
  import { existsSync, realpathSync } from "node:fs";
1116
- import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
1171
+ import { chmod, mkdir, rename, unlink } from "node:fs/promises";
1117
1172
  import { randomUUID } from "node:crypto";
1118
1173
  import os from "node:os";
1119
1174
  import path2 from "node:path";
1120
- import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
1121
- var DEFAULT_OPENCLAW_SQLITE = path2.join(os.homedir(), ".mobook", "state", "openclaw.sqlite");
1122
- var DEFAULT_OPENCLAW_SQLITE_ALT = path2.join(os.homedir(), ".openclaw", "state", "openclaw.sqlite");
1175
+ import { backup, DatabaseSync as DatabaseSync2 } from "node:sqlite";
1176
+ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
1123
1177
  function isRecord3(value) {
1124
1178
  return typeof value === "object" && value !== null && !Array.isArray(value);
1125
1179
  }
@@ -1181,18 +1235,22 @@ function overlayState(row, jobState, tableState) {
1181
1235
  ...isRecord3(jobState) ? jobState : {},
1182
1236
  ...isRecord3(tableState) ? tableState : {}
1183
1237
  };
1184
- if (row.next_run_at_ms !== null)
1238
+ if (typeof row.next_run_at_ms === "number")
1185
1239
  state.nextRunAtMs = row.next_run_at_ms;
1186
- if (row.running_at_ms !== null)
1240
+ if (typeof row.running_at_ms === "number")
1187
1241
  state.runningAtMs = row.running_at_ms;
1188
- if (row.last_run_at_ms !== null)
1242
+ if (typeof row.last_run_at_ms === "number")
1189
1243
  state.lastRunAtMs = row.last_run_at_ms;
1190
1244
  if (row.last_run_status)
1191
1245
  state.lastRunStatus = row.last_run_status;
1192
1246
  return state;
1193
1247
  }
1194
1248
  function jobFromOpenClawRow(row) {
1195
- const tableState = parseJson(row.state_json || "{}", "state_json");
1249
+ let tableState = {};
1250
+ try {
1251
+ tableState = parseJson(row.state_json || "{}", "state_json");
1252
+ } catch {
1253
+ }
1196
1254
  try {
1197
1255
  const parsed = parseJson(row.job_json, "job_json");
1198
1256
  if (!isRecord3(parsed))
@@ -1208,26 +1266,31 @@ function jobFromOpenClawRow(row) {
1208
1266
  return decodeJob(jobFromColumns(row, overlayState(row, {}, tableState)));
1209
1267
  }
1210
1268
  }
1211
- function loadOpenClawSqliteJobs(sqlitePath) {
1269
+ function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
1212
1270
  if (!existsSync(sqlitePath))
1213
- return [];
1271
+ return { jobs: [], skipped: [] };
1214
1272
  const database = new DatabaseSync2(sqlitePath, { readOnly: true, timeout: 5e3 });
1215
1273
  try {
1216
1274
  const table = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_jobs'").get();
1217
1275
  if (!table)
1218
- return [];
1276
+ return { jobs: [], skipped: [] };
1219
1277
  const rows = database.prepare("SELECT * FROM cron_jobs ORDER BY updated_at ASC, job_id ASC").all();
1220
1278
  const jobs = /* @__PURE__ */ new Map();
1279
+ const skipped = [];
1221
1280
  for (const row of rows) {
1222
1281
  try {
1223
1282
  const job = jobFromOpenClawRow(row);
1224
1283
  const previous = jobs.get(job.id);
1225
1284
  if (!previous || job.updatedAtMs >= previous.updatedAtMs)
1226
1285
  jobs.set(job.id, job);
1227
- } 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
+ });
1228
1291
  }
1229
1292
  }
1230
- return [...jobs.values()];
1293
+ return { jobs: [...jobs.values()], skipped };
1231
1294
  } finally {
1232
1295
  database.close();
1233
1296
  }
@@ -1235,12 +1298,16 @@ function loadOpenClawSqliteJobs(sqlitePath) {
1235
1298
  async function snapshotOpenClawSqlite(sqlitePath, destination) {
1236
1299
  const database = new DatabaseSync2(sqlitePath, { readOnly: true, timeout: 5e3 });
1237
1300
  try {
1238
- const serialized = database.serialize();
1239
1301
  const directory = path2.dirname(destination);
1240
1302
  const temporary = path2.join(directory, `.${path2.basename(destination)}.${randomUUID()}.tmp`);
1241
1303
  await mkdir(directory, { recursive: true });
1242
1304
  try {
1243
- await writeFile(temporary, serialized, { mode: 384 });
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
+ ]);
1244
1311
  await rename(temporary, destination);
1245
1312
  } catch (error) {
1246
1313
  await unlink(temporary).catch(() => void 0);
@@ -1250,12 +1317,55 @@ async function snapshotOpenClawSqlite(sqlitePath, destination) {
1250
1317
  database.close();
1251
1318
  }
1252
1319
  }
1320
+ function resolveOpenClawSqlitePath(configured, targetHome = dshHomePath("."), userHome = os.homedir()) {
1321
+ const explicit = configured?.trim() || process.env.OPENCLAW_SQLITE?.trim();
1322
+ if (explicit)
1323
+ return path2.resolve(explicit);
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"));
1333
+ }
1253
1334
  function openClawImportSource(sqlitePath) {
1254
1335
  const resolved = path2.resolve(sqlitePath);
1255
1336
  const canonical = existsSync(resolved) ? realpathSync.native(resolved) : resolved;
1256
1337
  return `openclaw-sqlite:${canonical}`;
1257
1338
  }
1258
1339
 
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
+
1259
1369
  // dist-npm/plugin/index.js
1260
1370
  var name = "@dcrays/scheduled-task";
1261
1371
  var inject = ["agentDefaultModel", "agents", "sessionPersistence", "webServer", "tools"];
@@ -1264,6 +1374,8 @@ var Schema = z;
1264
1374
  var Config = Schema.object({
1265
1375
  databaseFile: Schema.string().required(false),
1266
1376
  openclawSqlite: Schema.string().required(false),
1377
+ runtimeEnvironment: Schema.string().required(false),
1378
+ legacyDatabaseEnvironment: Schema.string().required(false),
1267
1379
  maxPromptChars: Schema.natural().min(1).default(16384),
1268
1380
  listPushIntervalSeconds: Schema.natural().min(1).default(30)
1269
1381
  });
@@ -1409,6 +1521,7 @@ function dueMessage(cron, occurrenceAt) {
1409
1521
  "[CRON AUTOMATION DUE]",
1410
1522
  "The user previously registered this cron automation. Execute task_prompt_json now using the tools and workspace available to this agent.",
1411
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.",
1412
1525
  `cron_id: ${JSON.stringify(cron.id)}`,
1413
1526
  `name: ${JSON.stringify(cron.name)}`,
1414
1527
  `occurrence_at: ${occurrenceAt}`,
@@ -1463,12 +1576,13 @@ var CronAutomationService = class extends Service {
1463
1576
  pendingPushReason;
1464
1577
  pushQueued = false;
1465
1578
  stopping = false;
1579
+ runtimeEnvironment;
1466
1580
  constructor(owner, config) {
1467
1581
  super(owner, "cronAutomations");
1468
1582
  this.owner = owner;
1469
1583
  this.config = config;
1470
- const configured = config.databaseFile?.trim();
1471
- const databaseFile = configured ? path3.resolve(configured) : dshHomePath("cron", "cron.sqlite");
1584
+ this.runtimeEnvironment = resolveCronRuntimeEnvironment(config.runtimeEnvironment);
1585
+ const databaseFile = resolveCronDatabaseFile(config.databaseFile, this.runtimeEnvironment);
1472
1586
  const logger = owner.logger("dsh-cron-automation");
1473
1587
  this.manager = new CronAutomationManager(new CronAutomationRepository(databaseFile), {
1474
1588
  maxPromptChars: config.maxPromptChars,
@@ -1511,6 +1625,7 @@ var CronAutomationService = class extends Service {
1511
1625
  }
1512
1626
  async initialize() {
1513
1627
  await this.manager.start();
1628
+ await this.migrateLegacyCronDatabase();
1514
1629
  await this.importOpenClawJobs();
1515
1630
  await this.publishList("startup");
1516
1631
  if (this.stopping)
@@ -1518,37 +1633,70 @@ var CronAutomationService = class extends Service {
1518
1633
  this.interval = setInterval(() => this.requestListPush("interval"), this.config.listPushIntervalSeconds * 1e3);
1519
1634
  this.interval.unref();
1520
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
+ }
1521
1653
  async importOpenClawJobs() {
1522
1654
  const explicit = this.config.openclawSqlite?.trim() || process.env["OPENCLAW_SQLITE"]?.trim();
1523
1655
  let sqlitePath;
1524
1656
  if (explicit) {
1525
- sqlitePath = path3.resolve(explicit);
1657
+ sqlitePath = path4.resolve(explicit);
1526
1658
  } else {
1527
- const stagingPath = dshHomePath("migration", "openclaw.sqlite");
1528
- const sourceCandidate = [DEFAULT_OPENCLAW_SQLITE, DEFAULT_OPENCLAW_SQLITE_ALT].find(existsSync2);
1529
- if (!existsSync2(stagingPath) && sourceCandidate) {
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) {
1530
1672
  try {
1531
1673
  await snapshotOpenClawSqlite(sourceCandidate, stagingPath);
1532
1674
  this.owner.logger("dsh-cron-automation").info("staged openclaw sqlite from %s to %s", sourceCandidate, stagingPath);
1533
1675
  } catch (error) {
1534
1676
  this.owner.logger("dsh-cron-automation").warn("failed to stage openclaw sqlite: %s", error instanceof Error ? error.message : String(error));
1677
+ return;
1535
1678
  }
1536
1679
  }
1537
1680
  sqlitePath = stagingPath;
1538
1681
  }
1539
- let incoming;
1682
+ let result;
1540
1683
  try {
1541
- incoming = loadOpenClawSqliteJobs(sqlitePath);
1684
+ result = loadOpenClawSqliteJobsWithDiagnostics(sqlitePath);
1542
1685
  } catch (error) {
1543
1686
  this.owner.logger("dsh-cron-automation").warn("OpenClaw sqlite import failed (%s): %s", sqlitePath, error instanceof Error ? error.message : String(error));
1544
1687
  return;
1545
1688
  }
1546
- if (incoming.length === 0)
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);
1547
1696
  return;
1548
- const added = await this.manager.importFrom(openClawImportSource(sqlitePath), incoming);
1549
- if (added.length > 0) {
1550
- this.owner.logger("dsh-cron-automation").info("imported %s OpenClaw cron job(s) from %s", added.length, sqlitePath);
1551
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);
1552
1700
  }
1553
1701
  async stop() {
1554
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
@@ -383,6 +383,29 @@ export class CronAutomationManager {
383
383
  return added.map(cloneJob);
384
384
  });
385
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
+ }
386
409
  async deliver(job, occurrenceAtMs) {
387
410
  try {
388
411
  return await this.options.deliver(cloneJob(job), new Date(occurrenceAtMs).toISOString());
@@ -1,6 +1,11 @@
1
1
  import { type StoredCronAutomation } from './repository.js';
2
- export declare const DEFAULT_OPENCLAW_SQLITE: string;
3
- export declare const DEFAULT_OPENCLAW_SQLITE_ALT: string;
2
+ export interface OpenClawSqliteImportResult {
3
+ jobs: StoredCronAutomation[];
4
+ skipped: Array<{
5
+ jobId: string;
6
+ reason: string;
7
+ }>;
8
+ }
4
9
  interface CronJobRow {
5
10
  job_id: string;
6
11
  name: string;
@@ -36,11 +41,13 @@ interface CronJobRow {
36
41
  }
37
42
  export declare function jobFromOpenClawRow(row: CronJobRow): StoredCronAutomation;
38
43
  export declare function loadOpenClawSqliteJobs(sqlitePath: string): StoredCronAutomation[];
44
+ export declare function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath: string): OpenClawSqliteImportResult;
39
45
  /**
40
46
  * Materialize a consistent OpenClaw database snapshot. Copying only the main
41
47
  * SQLite file loses transactions which are still in the source database WAL.
42
48
  */
43
49
  export declare function snapshotOpenClawSqlite(sqlitePath: string, destination: string): Promise<void>;
44
- export declare function resolveOpenClawSqlitePath(configured?: string): string;
50
+ export declare function resolveOpenClawSqlitePath(configured?: string, targetHome?: string, userHome?: string): string;
51
+ export declare function automaticOpenClawImportSource(targetHome?: string): string;
45
52
  export declare function openClawImportSource(sqlitePath: string): string;
46
53
  export {};
@@ -1,12 +1,11 @@
1
1
  import { existsSync, realpathSync } from 'node:fs';
2
- import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
2
+ import { chmod, mkdir, rename, unlink } from 'node:fs/promises';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import { DatabaseSync } from 'node:sqlite';
6
+ import { backup, DatabaseSync } from 'node:sqlite';
7
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
7
8
  import { decodeJob } from './repository.js';
8
- export const DEFAULT_OPENCLAW_SQLITE = path.join(os.homedir(), '.mobook', 'state', 'openclaw.sqlite');
9
- export const DEFAULT_OPENCLAW_SQLITE_ALT = path.join(os.homedir(), '.openclaw', 'state', 'openclaw.sqlite');
10
9
  function isRecord(value) {
11
10
  return typeof value === 'object' && value !== null && !Array.isArray(value);
12
11
  }
@@ -69,18 +68,27 @@ function overlayState(row, jobState, tableState) {
69
68
  ...(isRecord(jobState) ? jobState : {}),
70
69
  ...(isRecord(tableState) ? tableState : {})
71
70
  };
72
- if (row.next_run_at_ms !== null)
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')
73
74
  state.nextRunAtMs = row.next_run_at_ms;
74
- if (row.running_at_ms !== null)
75
+ if (typeof row.running_at_ms === 'number')
75
76
  state.runningAtMs = row.running_at_ms;
76
- if (row.last_run_at_ms !== null)
77
+ if (typeof row.last_run_at_ms === 'number')
77
78
  state.lastRunAtMs = row.last_run_at_ms;
78
79
  if (row.last_run_status)
79
80
  state.lastRunStatus = row.last_run_status;
80
81
  return state;
81
82
  }
82
83
  export function jobFromOpenClawRow(row) {
83
- const tableState = parseJson(row.state_json || '{}', 'state_json');
84
+ let tableState = {};
85
+ try {
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
+ }
84
92
  try {
85
93
  const parsed = parseJson(row.job_json, 'job_json');
86
94
  if (!isRecord(parsed))
@@ -98,19 +106,23 @@ export function jobFromOpenClawRow(row) {
98
106
  }
99
107
  }
100
108
  export function loadOpenClawSqliteJobs(sqlitePath) {
109
+ return loadOpenClawSqliteJobsWithDiagnostics(sqlitePath).jobs;
110
+ }
111
+ export function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
101
112
  if (!existsSync(sqlitePath))
102
- return [];
113
+ return { jobs: [], skipped: [] };
103
114
  const database = new DatabaseSync(sqlitePath, { readOnly: true, timeout: 5_000 });
104
115
  try {
105
116
  const table = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_jobs'").get();
106
117
  if (!table)
107
- return [];
118
+ return { jobs: [], skipped: [] };
108
119
  // SELECT * deliberately tolerates OpenClaw schema additions and older schemas
109
120
  // that omit newer nullable projection columns. job_json remains authoritative.
110
121
  const rows = database
111
122
  .prepare('SELECT * FROM cron_jobs ORDER BY updated_at ASC, job_id ASC')
112
123
  .all();
113
124
  const jobs = new Map();
125
+ const skipped = [];
114
126
  for (const row of rows) {
115
127
  try {
116
128
  const job = jobFromOpenClawRow(row);
@@ -118,11 +130,16 @@ export function loadOpenClawSqliteJobs(sqlitePath) {
118
130
  if (!previous || job.updatedAtMs >= previous.updatedAtMs)
119
131
  jobs.set(job.id, job);
120
132
  }
121
- catch {
122
- // 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
+ });
123
140
  }
124
141
  }
125
- return [...jobs.values()];
142
+ return { jobs: [...jobs.values()], skipped };
126
143
  }
127
144
  finally {
128
145
  database.close();
@@ -135,14 +152,18 @@ export function loadOpenClawSqliteJobs(sqlitePath) {
135
152
  export async function snapshotOpenClawSqlite(sqlitePath, destination) {
136
153
  const database = new DatabaseSync(sqlitePath, { readOnly: true, timeout: 5_000 });
137
154
  try {
138
- // @types/node 24.0 predates DatabaseSync.serialize(), which is available in
139
- // the Electron/Node runtime we require. Keep the compatibility shim local.
140
- const serialized = database.serialize();
141
155
  const directory = path.dirname(destination);
142
156
  const temporary = path.join(directory, `.${path.basename(destination)}.${randomUUID()}.tmp`);
143
157
  await mkdir(directory, { recursive: true });
144
158
  try {
145
- await writeFile(temporary, serialized, { mode: 0o600 });
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
+ ]);
146
167
  await rename(temporary, destination);
147
168
  }
148
169
  catch (error) {
@@ -154,11 +175,21 @@ export async function snapshotOpenClawSqlite(sqlitePath, destination) {
154
175
  database.close();
155
176
  }
156
177
  }
157
- export function resolveOpenClawSqlitePath(configured) {
178
+ export function resolveOpenClawSqlitePath(configured, targetHome = dshHomePath('.'), userHome = os.homedir()) {
158
179
  const explicit = configured?.trim() || process.env.OPENCLAW_SQLITE?.trim();
159
180
  if (explicit)
160
181
  return path.resolve(explicit);
161
- return DEFAULT_OPENCLAW_SQLITE;
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'));
162
193
  }
163
194
  export function openClawImportSource(sqlitePath) {
164
195
  const resolved = path.resolve(sqlitePath);
@@ -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
+ }