@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 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.0",
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.1",
42
- "@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
43
- "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
44
- "@deepseek-ai/dsh-tools": "^0.1.1-rc.2"
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
- // plugins/scheduled-task/dist-npm/plugin/index.js
4
- import path3 from "node:path";
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
- // plugins/scheduled-task/dist-npm/src/cron.js
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
- // plugins/scheduled-task/dist-npm/src/manager.js
211
+ // dist-npm/src/manager.js
212
212
  import { createHash } from "node:crypto";
213
213
 
214
- // plugins/scheduled-task/dist-npm/src/protocol.js
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
- // plugins/scheduled-task/dist-npm/src/repository.js
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
- // plugins/scheduled-task/dist-npm/src/manager.js
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
- // plugins/scheduled-task/dist-npm/src/openclaw-sqlite.js
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
- var DEFAULT_OPENCLAW_SQLITE = path2.join(os.homedir(), ".mobook", "state", "openclaw.sqlite");
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 !== null)
1238
+ if (typeof row.next_run_at_ms === "number")
1176
1239
  state.nextRunAtMs = row.next_run_at_ms;
1177
- if (row.running_at_ms !== null)
1240
+ if (typeof row.running_at_ms === "number")
1178
1241
  state.runningAtMs = row.running_at_ms;
1179
- if (row.last_run_at_ms !== null)
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
- const tableState = parseJson(row.state_json || "{}", "state_json");
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 loadOpenClawSqliteJobs(sqlitePath) {
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 resolveOpenClawSqlitePath(configured) {
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
- return DEFAULT_OPENCLAW_SQLITE;
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
- // plugins/scheduled-task/dist-npm/plugin/index.js
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
- const configured = config.databaseFile?.trim();
1450
- const databaseFile = configured ? path3.resolve(configured) : dshHomePath("cron", "cron.sqlite");
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 ?? cron.agentId);
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 sqlitePath = resolveOpenClawSqlitePath(this.config.openclawSqlite);
1498
- let incoming;
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
- incoming = loadOpenClawSqliteJobs(sqlitePath);
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 (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);
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 && job.state.runningAtMs === undefined && !this.blockedAgents.has(dshAgentIdFor(job)))
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;
@@ -1,5 +1,11 @@
1
- import { type StoredCronAutomation } from "./repository.js";
2
- export declare const DEFAULT_OPENCLAW_SQLITE: string;
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 resolveOpenClawSqlitePath(configured?: string): string;
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 {};
@@ -1,11 +1,13 @@
1
- import { existsSync, realpathSync } from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { DatabaseSync } from "node:sqlite";
5
- import { decodeJob } from "./repository.js";
6
- export const DEFAULT_OPENCLAW_SQLITE = path.join(os.homedir(), ".mobook", "state", "openclaw.sqlite");
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 === "object" && value !== null && !Array.isArray(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 === "every") {
21
+ if (row.schedule_kind === 'every') {
20
22
  return {
21
- kind: "every",
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 === "at") {
27
- return { kind: "at", ...(row.at === null ? {} : { at: row.at }) };
28
+ if (row.schedule_kind === 'at') {
29
+ return { kind: 'at', ...(row.at === null ? {} : { at: row.at }) };
28
30
  }
29
31
  return {
30
- kind: "cron",
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 || "agentTurn",
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 ?? "main",
51
+ agentId: row.agent_id ?? 'main',
50
52
  ...(row.session_key ? { sessionKey: row.session_key } : {}),
51
- sessionTarget: row.session_target || "isolated",
52
- wakeMode: row.wake_mode || "now",
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 ?? "announce",
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
- 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')
70
74
  state.nextRunAtMs = row.next_run_at_ms;
71
- if (row.running_at_ms !== null)
75
+ if (typeof row.running_at_ms === 'number')
72
76
  state.runningAtMs = row.running_at_ms;
73
- if (row.last_run_at_ms !== null)
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
- const tableState = parseJson(row.state_json || "{}", "state_json");
84
+ let tableState = {};
81
85
  try {
82
- const parsed = parseJson(row.job_json, "job_json");
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("job_json must be an object");
95
+ throw new Error('job_json must be an object');
85
96
  return decodeJob({
86
97
  ...parsed,
87
- id: typeof parsed.id === "string" && parsed.id ? parsed.id : row.job_id,
88
- updatedAtMs: typeof parsed.updatedAtMs === "number" ? parsed.updatedAtMs : row.updated_at,
89
- createdAtMs: typeof parsed.createdAtMs === "number" ? parsed.createdAtMs : row.created_at_ms,
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.prepare("SELECT * FROM cron_jobs ORDER BY updated_at ASC, job_id ASC").all();
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
- export function resolveOpenClawSqlitePath(configured) {
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
- 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'));
131
193
  }
132
194
  export function openClawImportSource(sqlitePath) {
133
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
+ }