@dcrays/scheduled-task 0.1.4 → 0.1.6-beta.1

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/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
- // dist-npm/plugin/index.js
3
+ // dist/plugin/index.js
4
4
  import { existsSync as existsSync3 } from "node:fs";
5
- import path4 from "node:path";
5
+ import path5 from "node:path";
6
6
  import { Service } from "@deepseek-ai/cordis";
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
- // dist-npm/src/cron.js
11
+ // dist/src/cron.js
12
12
  var FIELD_COUNT = 5;
13
13
  var MAX_SEARCH_DAYS = 366 * 8;
14
14
  var GREGORIAN_CYCLE_DAYS = 146097;
@@ -34,7 +34,7 @@ function parseInteger(value, label) {
34
34
  }
35
35
  function parseField(raw, min, max, label, normalize) {
36
36
  const values = /* @__PURE__ */ new Set();
37
- const wildcard = raw === "*" || raw.startsWith("*/");
37
+ const wildcard = raw === "*";
38
38
  if (raw.length === 0)
39
39
  throw new CronExpressionError(`${label} is empty`);
40
40
  for (const segment of raw.split(",")) {
@@ -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
- // dist-npm/src/manager.js
211
+ // dist/src/manager.js
212
212
  import { createHash } from "node:crypto";
213
213
 
214
- // dist-npm/src/protocol.js
214
+ // dist/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
- // dist-npm/src/repository.js
305
+ // dist/src/repository.js
306
306
  import fs from "node:fs";
307
307
  import path from "node:path";
308
308
  import { DatabaseSync } from "node:sqlite";
@@ -579,6 +579,11 @@ var CronAutomationRepository = class {
579
579
  } catch {
580
580
  }
581
581
  }
582
+ hasLegacyMigration(migrationId) {
583
+ this.assertOpen();
584
+ const table = this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_migrations'").get();
585
+ return Boolean(table && this.database.prepare("SELECT 1 FROM cron_migrations WHERE migration_id = ?").get(migrationId));
586
+ }
582
587
  async load() {
583
588
  this.assertOpen();
584
589
  const rows = this.database.prepare(`
@@ -634,6 +639,24 @@ var CronAutomationRepository = class {
634
639
  return [];
635
640
  return this.database.prepare("SELECT source, job_id FROM cron_imported_jobs").all().map((row) => ({ source: row.source, jobId: row.job_id }));
636
641
  }
642
+ /** Only a verified complete source snapshot may authorize these deletions. */
643
+ async removeMissingImportedJobs(source, presentIds) {
644
+ this.assertWritable();
645
+ if (!source.trim())
646
+ throw new Error("cron import source must be non-empty");
647
+ return this.writeTransaction(() => {
648
+ const rows = this.database.prepare(`
649
+ SELECT jobs.job_id FROM cron_jobs AS jobs
650
+ INNER JOIN cron_imported_jobs AS imported ON imported.job_id = jobs.job_id
651
+ WHERE jobs.store_key = ? AND imported.source = ?
652
+ `).all(STORE_KEY, source);
653
+ const removed = rows.map((row) => row.job_id).filter((id) => !presentIds.has(id));
654
+ const statement = this.database.prepare("DELETE FROM cron_jobs WHERE store_key = ? AND job_id = ?");
655
+ for (const id of removed)
656
+ statement.run(STORE_KEY, id);
657
+ return removed;
658
+ });
659
+ }
637
660
  async migrateLegacy(migrationId, incoming, importedJobs) {
638
661
  this.assertWritable();
639
662
  const decoded = uniqueJobs(incoming);
@@ -714,8 +737,9 @@ var CronAutomationRepository = class {
714
737
  }
715
738
  };
716
739
 
717
- // dist-npm/src/manager.js
740
+ // dist/src/manager.js
718
741
  var MAX_TIMER_DELAY_MS = 2147483647;
742
+ var DELIVERY_RETRY_DELAY_MS = 3e4;
719
743
  function automationId(agentId, requestId) {
720
744
  return `cron-${createHash("sha256").update(`${agentId}\0${requestId}`).digest("hex").slice(0, 24)}`;
721
745
  }
@@ -802,15 +826,25 @@ var CronAutomationManager = class {
802
826
  options;
803
827
  jobs = /* @__PURE__ */ new Map();
804
828
  blockedAgents = /* @__PURE__ */ new Set();
829
+ blockedUntil = /* @__PURE__ */ new Map();
830
+ activeJobs = /* @__PURE__ */ new Set();
831
+ inflight = /* @__PURE__ */ new Set();
805
832
  now;
806
833
  started;
807
834
  tail = Promise.resolve();
835
+ dispatchLoop = Promise.resolve();
808
836
  timer;
809
837
  stopping = false;
838
+ schedulingPaused;
810
839
  constructor(repository, options) {
811
840
  this.repository = repository;
812
841
  this.options = options;
813
842
  this.now = options.now ?? Date.now;
843
+ this.schedulingPaused = options.startPaused ?? false;
844
+ }
845
+ resumeScheduling() {
846
+ this.schedulingPaused = false;
847
+ this.arm();
814
848
  }
815
849
  start() {
816
850
  return this.started ??= this.initialize();
@@ -842,6 +876,8 @@ var CronAutomationManager = class {
842
876
  clearTimeout(this.timer);
843
877
  this.timer = void 0;
844
878
  try {
879
+ await this.dispatchLoop.catch(() => void 0);
880
+ await Promise.all([...this.inflight]);
845
881
  await this.tail;
846
882
  } finally {
847
883
  this.repository.close();
@@ -1012,31 +1048,12 @@ var CronAutomationManager = class {
1012
1048
  }
1013
1049
  async runOnceStatus(jobId) {
1014
1050
  await this.start();
1015
- return this.enqueue(async () => {
1016
- const job = this.jobs.get(jobId);
1017
- if (!job)
1018
- return "not_found";
1019
- if (job.state.runningAtMs !== void 0)
1020
- return "already_running";
1021
- if (!hasDshSession(job))
1022
- return "delivery_unavailable";
1023
- const occurrenceAtMs = this.now();
1024
- job.state.runningAtMs = occurrenceAtMs;
1025
- job.updatedAtMs = occurrenceAtMs;
1026
- await this.repository.upsert([job]);
1027
- const delivered = await this.deliver(job, occurrenceAtMs);
1028
- delete job.state.runningAtMs;
1029
- job.state.lastRunAtMs = occurrenceAtMs;
1030
- job.state.lastRunStatus = delivered ? "ok" : "skipped";
1031
- job.updatedAtMs = this.now();
1032
- await this.repository.upsert([job]);
1033
- if (delivered) {
1034
- this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
1035
- return "dispatched";
1036
- }
1037
- this.options.onChanged?.(cloneJob(job));
1038
- return "delivery_unavailable";
1039
- });
1051
+ const claimed = await this.enqueue(() => this.claimManualRun(jobId));
1052
+ if (claimed.kind === "status")
1053
+ return claimed.status;
1054
+ const delivered = await this.deliverTracked(claimed.job, claimed.occurrenceAtMs);
1055
+ await this.enqueue(() => this.finalizeManualRun(claimed.job.id, claimed.occurrenceAtMs, delivered));
1056
+ return delivered ? "dispatched" : "delivery_unavailable";
1040
1057
  }
1041
1058
  async runOnce(jobId) {
1042
1059
  return await this.runOnceStatus(jobId) === "dispatched";
@@ -1047,10 +1064,25 @@ var CronAutomationManager = class {
1047
1064
  return [...this.jobs.values()].filter((job) => agentId === void 0 || dshAgentIdFor(job) === agentId).sort((left, right) => left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id)).map(cloneJob);
1048
1065
  }
1049
1066
  notifyAgentAvailable(agentId) {
1050
- if (!this.blockedAgents.delete(agentId))
1051
- return;
1067
+ this.blockedAgents.delete(agentId);
1068
+ this.blockedUntil.delete(agentId);
1052
1069
  this.requestDispatch();
1053
1070
  }
1071
+ /** Reflect deletions only for ids recorded in this source's import ledger. */
1072
+ async reconcileImportedDeletions(source, presentIds) {
1073
+ await this.start();
1074
+ return this.enqueue(async () => {
1075
+ const removed = await this.repository.removeMissingImportedJobs(source, presentIds);
1076
+ for (const id of removed) {
1077
+ const job = this.jobs.get(id);
1078
+ this.jobs.delete(id);
1079
+ if (job)
1080
+ this.options.onChanged?.(cloneJob(job));
1081
+ }
1082
+ this.arm();
1083
+ return removed;
1084
+ });
1085
+ }
1054
1086
  /** Import each source job once. Existing ids win so DSH bindings stay intact. */
1055
1087
  async importFrom(source, incoming) {
1056
1088
  await this.start();
@@ -1107,22 +1139,124 @@ var CronAutomationManager = class {
1107
1139
  return false;
1108
1140
  }
1109
1141
  }
1142
+ async deliverTracked(job, occurrenceAtMs) {
1143
+ const work = this.deliver(job, occurrenceAtMs);
1144
+ this.inflight.add(work);
1145
+ try {
1146
+ return await work;
1147
+ } finally {
1148
+ this.inflight.delete(work);
1149
+ }
1150
+ }
1151
+ async claimManualRun(jobId) {
1152
+ if (this.stopping)
1153
+ return { kind: "status", status: "delivery_unavailable" };
1154
+ if (this.activeJobs.has(jobId))
1155
+ return { kind: "status", status: "already_running" };
1156
+ const job = this.jobs.get(jobId);
1157
+ if (!job)
1158
+ return { kind: "status", status: "not_found" };
1159
+ if (job.state.runningAtMs !== void 0)
1160
+ return { kind: "status", status: "already_running" };
1161
+ if (!hasDshSession(job))
1162
+ return { kind: "status", status: "delivery_unavailable" };
1163
+ const occurrenceAtMs = this.now();
1164
+ this.activeJobs.add(jobId);
1165
+ job.state.runningAtMs = occurrenceAtMs;
1166
+ job.updatedAtMs = occurrenceAtMs;
1167
+ try {
1168
+ await this.repository.upsert([job]);
1169
+ } catch (error) {
1170
+ this.activeJobs.delete(jobId);
1171
+ delete job.state.runningAtMs;
1172
+ throw error;
1173
+ }
1174
+ return { kind: "ready", job: cloneJob(job), occurrenceAtMs };
1175
+ }
1176
+ async finalizeManualRun(jobId, occurrenceAtMs, delivered) {
1177
+ this.activeJobs.delete(jobId);
1178
+ const job = this.jobs.get(jobId);
1179
+ if (!job)
1180
+ return;
1181
+ delete job.state.runningAtMs;
1182
+ job.state.lastRunAtMs = occurrenceAtMs;
1183
+ job.state.lastRunStatus = delivered ? "ok" : "skipped";
1184
+ job.updatedAtMs = this.now();
1185
+ await this.repository.upsert([job]);
1186
+ if (delivered)
1187
+ this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
1188
+ else
1189
+ this.options.onChanged?.(cloneJob(job));
1190
+ }
1191
+ async claimNextDueJob() {
1192
+ if (this.stopping || this.schedulingPaused)
1193
+ return void 0;
1194
+ const now = this.now();
1195
+ const job = [...this.jobs.values()].filter((candidate) => candidate.enabled && hasDshSession(candidate) && candidate.state.runningAtMs === void 0 && !this.activeJobs.has(candidate.id) && candidate.state.nextRunAtMs !== void 0 && candidate.state.nextRunAtMs <= now && (!this.blockedAgents.has(dshAgentIdFor(candidate)) || (this.blockedUntil.get(dshAgentIdFor(candidate)) ?? 0) <= now)).sort((left, right) => (left.state.nextRunAtMs ?? 0) - (right.state.nextRunAtMs ?? 0) || left.createdAtMs - right.createdAtMs)[0];
1196
+ if (!job)
1197
+ return void 0;
1198
+ const occurrenceAtMs = job.state.nextRunAtMs ?? now;
1199
+ this.activeJobs.add(job.id);
1200
+ job.state.runningAtMs = now;
1201
+ job.updatedAtMs = now;
1202
+ try {
1203
+ await this.repository.upsert([job]);
1204
+ } catch (error) {
1205
+ this.activeJobs.delete(job.id);
1206
+ delete job.state.runningAtMs;
1207
+ throw error;
1208
+ }
1209
+ return { job: cloneJob(job), occurrenceAtMs };
1210
+ }
1211
+ async finalizeScheduledRun(jobId, occurrenceAtMs, delivered) {
1212
+ this.activeJobs.delete(jobId);
1213
+ const job = this.jobs.get(jobId);
1214
+ if (!job)
1215
+ return;
1216
+ const now = this.now();
1217
+ delete job.state.runningAtMs;
1218
+ job.state.lastRunAtMs = now;
1219
+ job.state.lastRunStatus = delivered ? "ok" : "skipped";
1220
+ job.updatedAtMs = now;
1221
+ if (!delivered) {
1222
+ this.blockedAgents.add(dshAgentIdFor(job));
1223
+ this.blockedUntil.set(dshAgentIdFor(job), now + DELIVERY_RETRY_DELAY_MS);
1224
+ await this.repository.upsert([job]);
1225
+ this.options.onChanged?.(cloneJob(job));
1226
+ return;
1227
+ }
1228
+ this.blockedAgents.delete(dshAgentIdFor(job));
1229
+ this.blockedUntil.delete(dshAgentIdFor(job));
1230
+ if (job.deleteAfterRun === true || job.schedule.kind === "at") {
1231
+ this.jobs.delete(jobId);
1232
+ await this.repository.delete(jobId);
1233
+ } else {
1234
+ if (job.enabled)
1235
+ setNextOccurrence(job, nextOccurrence(now, job.schedule));
1236
+ await this.repository.upsert([job]);
1237
+ }
1238
+ this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
1239
+ }
1110
1240
  enqueue(operation) {
1111
1241
  const run = this.tail.then(operation);
1112
1242
  this.tail = run.then(() => void 0, () => void 0);
1113
1243
  return run;
1114
1244
  }
1115
1245
  requestDispatch() {
1116
- void this.enqueue(() => this.dispatchDue()).catch((error) => this.options.onError?.(error));
1246
+ this.dispatchLoop = this.dispatchLoop.then(() => this.dispatchDue(), () => this.dispatchDue());
1247
+ void this.dispatchLoop.catch((error) => this.options.onError?.(error));
1117
1248
  }
1118
1249
  arm() {
1119
- if (this.stopping)
1250
+ if (this.stopping || this.schedulingPaused)
1120
1251
  return;
1121
1252
  if (this.timer)
1122
1253
  clearTimeout(this.timer);
1123
1254
  this.timer = void 0;
1124
1255
  const now = this.now();
1125
- const target = [...this.jobs.values()].filter((job) => job.enabled && hasDshSession(job) && job.state.runningAtMs === void 0 && !this.blockedAgents.has(dshAgentIdFor(job))).map((job) => job.state.nextRunAtMs).filter((value) => value !== void 0).reduce((earliest, candidate) => earliest === void 0 || candidate < earliest ? candidate : earliest, void 0);
1256
+ const target = [...this.jobs.values()].filter((job) => job.enabled && hasDshSession(job) && job.state.runningAtMs === void 0 && (!this.blockedAgents.has(dshAgentIdFor(job)) || (this.blockedUntil.get(dshAgentIdFor(job)) ?? 0) <= now)).map((job) => {
1257
+ const retryAt = this.blockedUntil.get(dshAgentIdFor(job));
1258
+ return retryAt !== void 0 && retryAt > now ? retryAt : job.state.nextRunAtMs;
1259
+ }).filter((value) => value !== void 0).reduce((earliest, candidate) => earliest === void 0 || candidate < earliest ? candidate : earliest, void 0);
1126
1260
  if (target === void 0)
1127
1261
  return;
1128
1262
  const delay = Math.max(0, Math.min(target - now, MAX_TIMER_DELAY_MS));
@@ -1133,40 +1267,22 @@ var CronAutomationManager = class {
1133
1267
  this.timer.unref();
1134
1268
  }
1135
1269
  async dispatchDue() {
1136
- if (this.stopping)
1270
+ if (this.stopping || this.schedulingPaused)
1137
1271
  return;
1138
- const now = this.now();
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);
1140
- for (const job of due) {
1141
- const occurrenceAtMs = job.state.nextRunAtMs ?? now;
1142
- job.state.runningAtMs = now;
1143
- job.updatedAtMs = now;
1144
- await this.repository.upsert([job]);
1145
- const delivered = await this.deliver(job, occurrenceAtMs);
1146
- delete job.state.runningAtMs;
1147
- job.state.lastRunAtMs = now;
1148
- job.state.lastRunStatus = delivered ? "ok" : "skipped";
1149
- job.updatedAtMs = now;
1150
- if (!delivered) {
1151
- this.blockedAgents.add(dshAgentIdFor(job));
1152
- await this.repository.upsert([job]);
1153
- this.options.onChanged?.(cloneJob(job));
1154
- continue;
1155
- }
1156
- if (job.deleteAfterRun === true || job.schedule.kind === "at") {
1157
- this.jobs.delete(job.id);
1158
- await this.repository.delete(job.id);
1159
- } else {
1160
- setNextOccurrence(job, nextOccurrence(now, job.schedule));
1161
- await this.repository.upsert([job]);
1162
- }
1163
- this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
1272
+ for (; ; ) {
1273
+ const claimed = await this.enqueue(() => this.claimNextDueJob());
1274
+ if (!claimed)
1275
+ break;
1276
+ const delivered = await this.deliverTracked(claimed.job, claimed.occurrenceAtMs);
1277
+ await this.enqueue(() => this.finalizeScheduledRun(claimed.job.id, claimed.occurrenceAtMs, delivered));
1164
1278
  }
1165
- this.arm();
1279
+ await this.enqueue(async () => {
1280
+ this.arm();
1281
+ });
1166
1282
  }
1167
1283
  };
1168
1284
 
1169
- // dist-npm/src/openclaw-sqlite.js
1285
+ // dist/src/openclaw-sqlite.js
1170
1286
  import { existsSync, realpathSync } from "node:fs";
1171
1287
  import { chmod, mkdir, rename, unlink } from "node:fs/promises";
1172
1288
  import { randomUUID } from "node:crypto";
@@ -1268,12 +1384,12 @@ function jobFromOpenClawRow(row) {
1268
1384
  }
1269
1385
  function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
1270
1386
  if (!existsSync(sqlitePath))
1271
- return { jobs: [], skipped: [] };
1387
+ return { jobs: [], skipped: [], complete: false };
1272
1388
  const database = new DatabaseSync2(sqlitePath, { readOnly: true, timeout: 5e3 });
1273
1389
  try {
1274
1390
  const table = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_jobs'").get();
1275
1391
  if (!table)
1276
- return { jobs: [], skipped: [] };
1392
+ return { jobs: [], skipped: [], complete: false };
1277
1393
  const rows = database.prepare("SELECT * FROM cron_jobs ORDER BY updated_at ASC, job_id ASC").all();
1278
1394
  const jobs = /* @__PURE__ */ new Map();
1279
1395
  const skipped = [];
@@ -1290,7 +1406,7 @@ function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
1290
1406
  });
1291
1407
  }
1292
1408
  }
1293
- return { jobs: [...jobs.values()], skipped };
1409
+ return { jobs: [...jobs.values()], skipped, complete: skipped.length === 0 };
1294
1410
  } finally {
1295
1411
  database.close();
1296
1412
  }
@@ -1337,9 +1453,38 @@ function openClawImportSource(sqlitePath) {
1337
1453
  return `openclaw-sqlite:${canonical}`;
1338
1454
  }
1339
1455
 
1340
- // dist-npm/src/runtime-environment.js
1456
+ // dist/src/output.js
1457
+ import { createHash as createHash2 } from "node:crypto";
1458
+ import { mkdirSync } from "node:fs";
1341
1459
  import path3 from "node:path";
1342
- import { existsSync as existsSync2, readFileSync, realpathSync as realpathSync2 } from "node:fs";
1460
+ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
1461
+ function resolveTaskOutputRoot(globalOutput, workspace) {
1462
+ const output = path3.resolve(globalOutput);
1463
+ if (!workspace)
1464
+ return output;
1465
+ const resolved = path3.resolve(workspace);
1466
+ if (resolved === output)
1467
+ return output;
1468
+ const workspaceFolder = path3.dirname(output);
1469
+ const home = path3.dirname(workspaceFolder);
1470
+ if (resolved === home || resolved === workspaceFolder)
1471
+ return output;
1472
+ return path3.join(resolved, "output");
1473
+ }
1474
+ function cronOutputDirectory(cronId, workspace, dshHome = resolveDshHome()) {
1475
+ const segment = (value) => createHash2("sha256").update(value).digest("hex").slice(0, 24);
1476
+ const base = resolveTaskOutputRoot(path3.join(dshHome, "workspace", "output"), workspace);
1477
+ const root = path3.join(base, segment(`cron:${cronId}`));
1478
+ for (const directory of ["files", "tmp", "previews", "downloads"]) {
1479
+ mkdirSync(path3.join(root, directory), { recursive: true });
1480
+ }
1481
+ return root;
1482
+ }
1483
+
1484
+ // dist/src/runtime-environment.js
1485
+ import { randomUUID as randomUUID2 } from "node:crypto";
1486
+ import path4 from "node:path";
1487
+ import { existsSync as existsSync2, linkSync, mkdirSync as mkdirSync2, readFileSync, realpathSync as realpathSync2, unlinkSync, writeFileSync } from "node:fs";
1343
1488
  import { dshHomePath as dshHomePath2 } from "@deepseek-ai/dsh-home-paths";
1344
1489
  var CRON_RUNTIME_ENVIRONMENTS = ["development", "test", "production"];
1345
1490
  function resolveCronRuntimeEnvironment(value) {
@@ -1350,32 +1495,123 @@ function resolveCronRuntimeEnvironment(value) {
1350
1495
  throw new Error(`Unsupported cron runtime environment: ${environment}`);
1351
1496
  }
1352
1497
  function readDesktopRuntimeEnvironment() {
1498
+ let contents;
1499
+ try {
1500
+ contents = readFileSync(dshHomePath2("mobook.json"), "utf8");
1501
+ } catch (error) {
1502
+ if (error.code === "ENOENT")
1503
+ return void 0;
1504
+ throw error;
1505
+ }
1506
+ let parsed;
1353
1507
  try {
1354
- const parsed = JSON.parse(readFileSync(dshHomePath2("mobook.json"), "utf8"));
1355
- const value = parsed && typeof parsed === "object" ? parsed.environment : void 0;
1356
- return typeof value === "string" && value.trim() ? value.trim() : void 0;
1508
+ parsed = JSON.parse(contents);
1357
1509
  } catch {
1510
+ throw new Error(`Invalid mobook.json: ${dshHomePath2("mobook.json")}`);
1511
+ }
1512
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1513
+ throw new Error(`Unsupported mobook.json root value: ${dshHomePath2("mobook.json")}`);
1514
+ }
1515
+ const value = parsed.environment;
1516
+ if (value === void 0)
1358
1517
  return void 0;
1518
+ if (typeof value !== "string" || !value.trim()) {
1519
+ throw new Error("mobook.json environment must be a non-empty string");
1359
1520
  }
1521
+ return value.trim();
1360
1522
  }
1361
1523
  function cronRuntimePath(environment, ...segments) {
1362
1524
  return dshHomePath2("cron", environment, ...segments);
1363
1525
  }
1364
1526
  function resolveCronDatabaseFile(configured, environment) {
1365
- return configured?.trim() ? path3.resolve(configured) : cronRuntimePath(environment, "cron.sqlite");
1527
+ return configured?.trim() ? path4.resolve(configured) : cronRuntimePath(environment, "cron.sqlite");
1528
+ }
1529
+ function resolveLegacyCronOwner(configured, recordedEnvironment = void 0, runtimeEnvironment = "production") {
1530
+ if (configured?.trim())
1531
+ return resolveCronRuntimeEnvironment(configured);
1532
+ if (recordedEnvironment?.trim())
1533
+ return "production";
1534
+ return runtimeEnvironment;
1366
1535
  }
1367
- function resolveLegacyCronOwner(value) {
1368
- return resolveCronRuntimeEnvironment(value?.trim() || "production");
1536
+ function legacyCronOwnerFile() {
1537
+ return dshHomePath2("cron", "legacy-owner.json");
1538
+ }
1539
+ function readLegacyCronOwner(file) {
1540
+ let parsed;
1541
+ try {
1542
+ parsed = JSON.parse(readFileSync(file, "utf8"));
1543
+ } catch (cause) {
1544
+ throw new Error(`Invalid legacy cron owner marker: ${file}`, { cause });
1545
+ }
1546
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1547
+ throw new Error(`Invalid legacy cron owner marker: ${file}`);
1548
+ }
1549
+ const record = parsed;
1550
+ if (record.version !== 1 || !CRON_RUNTIME_ENVIRONMENTS.includes(record.owner)) {
1551
+ throw new Error(`Invalid legacy cron owner marker: ${file}`);
1552
+ }
1553
+ return record.owner;
1554
+ }
1555
+ function previouslyMigratedOwner() {
1556
+ const migrationId = legacyCronMigrationId(legacyCronDatabaseFile());
1557
+ const owners = [];
1558
+ for (const environment of CRON_RUNTIME_ENVIRONMENTS) {
1559
+ const file = cronRuntimePath(environment, "cron.sqlite");
1560
+ if (!existsSync2(file))
1561
+ continue;
1562
+ const repository = new CronAutomationRepository(file, { readOnly: true });
1563
+ try {
1564
+ if (repository.hasLegacyMigration(migrationId))
1565
+ owners.push(environment);
1566
+ } finally {
1567
+ repository.close();
1568
+ }
1569
+ }
1570
+ if (owners.length > 1) {
1571
+ throw new Error(`Legacy cron database was already migrated into multiple environments: ${owners.join(", ")}`);
1572
+ }
1573
+ return owners[0];
1574
+ }
1575
+ function claimLegacyCronOwner(configured, recordedEnvironment, runtimeEnvironment) {
1576
+ const file = legacyCronOwnerFile();
1577
+ if (existsSync2(file))
1578
+ return readLegacyCronOwner(file);
1579
+ const owner = previouslyMigratedOwner() ?? resolveLegacyCronOwner(configured, recordedEnvironment, runtimeEnvironment);
1580
+ mkdirSync2(path4.dirname(file), { recursive: true });
1581
+ const temporary = `${file}.tmp-${process.pid}-${randomUUID2()}`;
1582
+ try {
1583
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, owner })}
1584
+ `, {
1585
+ encoding: "utf8",
1586
+ mode: 384,
1587
+ flag: "wx"
1588
+ });
1589
+ try {
1590
+ linkSync(temporary, file);
1591
+ return owner;
1592
+ } catch (error) {
1593
+ if (error.code !== "EEXIST")
1594
+ throw error;
1595
+ return readLegacyCronOwner(file);
1596
+ }
1597
+ } finally {
1598
+ try {
1599
+ unlinkSync(temporary);
1600
+ } catch (error) {
1601
+ if (error.code !== "ENOENT")
1602
+ throw error;
1603
+ }
1604
+ }
1369
1605
  }
1370
1606
  function legacyCronDatabaseFile() {
1371
1607
  return dshHomePath2("cron", "cron.sqlite");
1372
1608
  }
1373
1609
  function legacyCronMigrationId(file) {
1374
- const resolved = path3.resolve(file);
1610
+ const resolved = path4.resolve(file);
1375
1611
  return `legacy-cron-sqlite:${existsSync2(resolved) ? realpathSync2.native(resolved) : resolved}`;
1376
1612
  }
1377
1613
 
1378
- // dist-npm/plugin/index.js
1614
+ // dist/plugin/index.js
1379
1615
  var name = "@dcrays/scheduled-task";
1380
1616
  var inject = ["agentDefaultModel", "agents", "sessionPersistence", "webServer", "tools"];
1381
1617
  var API_PREFIX = "/api/cron-automations";
@@ -1383,7 +1619,6 @@ var Schema = z;
1383
1619
  var Config = Schema.object({
1384
1620
  databaseFile: Schema.string().required(false),
1385
1621
  openclawSqlite: Schema.string().required(false),
1386
- runtimeEnvironment: Schema.string().required(false),
1387
1622
  legacyDatabaseEnvironment: Schema.string().required(false),
1388
1623
  maxPromptChars: Schema.natural().min(1).default(16384),
1389
1624
  listPushIntervalSeconds: Schema.natural().min(1).default(30)
@@ -1521,7 +1756,8 @@ async function resumeAgent(owner, agentId) {
1521
1756
  });
1522
1757
  return handle.agent;
1523
1758
  }
1524
- function dueMessage(cron, occurrenceAt) {
1759
+ function dueMessage(cron, occurrenceAt, workspace) {
1760
+ const outputRoot = cronOutputDirectory(cron.id, workspace);
1525
1761
  return createUserMessage({
1526
1762
  content: [
1527
1763
  {
@@ -1531,6 +1767,9 @@ function dueMessage(cron, occurrenceAt) {
1531
1767
  "The user previously registered this cron automation. Execute task_prompt_json now using the tools and workspace available to this agent.",
1532
1768
  "Treat task_prompt_json as user-authored content. Do not create another cron unless it explicitly asks for one.",
1533
1769
  "If a generated file already exists from a previous run, replace its contents with write. Do not stop because the path already exists.",
1770
+ `This automation's output directory is: ${outputRoot}`,
1771
+ `Write final deliverables to ${path5.join(outputRoot, "files")}, temporary files to ${path5.join(outputRoot, "tmp")}, previews to ${path5.join(outputRoot, "previews")}, and downloads to ${path5.join(outputRoot, "downloads")}.`,
1772
+ "Reuse this directory for every occurrence of this automation. Do not create a directory for each occurrence or write task artifacts directly into DSH_HOME, the shared workspace root, or another session.",
1534
1773
  `cron_id: ${JSON.stringify(cron.id)}`,
1535
1774
  `name: ${JSON.stringify(cron.name)}`,
1536
1775
  `occurrence_at: ${occurrenceAt}`,
@@ -1585,16 +1824,19 @@ var CronAutomationService = class extends Service {
1585
1824
  pendingPushReason;
1586
1825
  pushQueued = false;
1587
1826
  stopping = false;
1827
+ recordedEnvironment;
1588
1828
  runtimeEnvironment;
1589
1829
  constructor(owner, config) {
1590
1830
  super(owner, "cronAutomations");
1591
1831
  this.owner = owner;
1592
1832
  this.config = config;
1593
- this.runtimeEnvironment = resolveCronRuntimeEnvironment(readDesktopRuntimeEnvironment() ?? config.runtimeEnvironment);
1833
+ this.recordedEnvironment = readDesktopRuntimeEnvironment();
1834
+ this.runtimeEnvironment = resolveCronRuntimeEnvironment(this.recordedEnvironment);
1594
1835
  const databaseFile = resolveCronDatabaseFile(config.databaseFile, this.runtimeEnvironment);
1595
1836
  const logger = owner.logger("dsh-cron-automation");
1596
1837
  this.manager = new CronAutomationManager(new CronAutomationRepository(databaseFile), {
1597
1838
  maxPromptChars: config.maxPromptChars,
1839
+ startPaused: true,
1598
1840
  deliver: async (cron, occurrenceAt) => {
1599
1841
  if (!cron.dshSessionId) {
1600
1842
  logger.warn("cron %s is waiting for an MBHChat DSH session binding", cron.id);
@@ -1611,7 +1853,8 @@ var CronAutomationService = class extends Service {
1611
1853
  logger.warn("cron %s is due but agent %s is not live", cron.id, cron.agentId);
1612
1854
  return false;
1613
1855
  }
1614
- agent.followup(dueMessage(cron, occurrenceAt));
1856
+ agent.followup(dueMessage(cron, occurrenceAt, agent.session.header.cwd));
1857
+ await agent.whenIdle();
1615
1858
  return true;
1616
1859
  },
1617
1860
  onError: (error) => logger.warn("cron automation runtime failed: %s", error instanceof Error ? error.message : String(error)),
@@ -1636,6 +1879,7 @@ var CronAutomationService = class extends Service {
1636
1879
  await this.manager.start();
1637
1880
  await this.migrateLegacyCronDatabase();
1638
1881
  await this.importOpenClawJobs();
1882
+ this.manager.resumeScheduling();
1639
1883
  await this.publishList("startup");
1640
1884
  if (this.stopping)
1641
1885
  return;
@@ -1643,12 +1887,14 @@ var CronAutomationService = class extends Service {
1643
1887
  this.interval.unref();
1644
1888
  }
1645
1889
  async migrateLegacyCronDatabase() {
1646
- const owner = resolveLegacyCronOwner(this.config.legacyDatabaseEnvironment);
1647
- if (owner !== this.runtimeEnvironment)
1648
- return;
1649
1890
  const legacyFile = legacyCronDatabaseFile();
1650
1891
  if (!existsSync3(legacyFile))
1651
1892
  return;
1893
+ const owner = claimLegacyCronOwner(this.config.legacyDatabaseEnvironment, this.recordedEnvironment, this.runtimeEnvironment);
1894
+ if (owner !== this.runtimeEnvironment) {
1895
+ this.owner.logger("dsh-cron-automation").info("leaving legacy Harness cron database in place; it belongs to %s, current runtime is %s", owner, this.runtimeEnvironment);
1896
+ return;
1897
+ }
1652
1898
  const legacy = new CronAutomationRepository(legacyFile, { readOnly: true });
1653
1899
  try {
1654
1900
  const result = await this.manager.migrateLegacy(legacyCronMigrationId(legacyFile), await legacy.load(), await legacy.listImportedJobs());
@@ -1663,7 +1909,7 @@ var CronAutomationService = class extends Service {
1663
1909
  const explicit = this.config.openclawSqlite?.trim() || process.env["OPENCLAW_SQLITE"]?.trim();
1664
1910
  let sqlitePath;
1665
1911
  if (explicit) {
1666
- sqlitePath = path4.resolve(explicit);
1912
+ sqlitePath = path5.resolve(explicit);
1667
1913
  } else {
1668
1914
  const stagingPath = cronRuntimePath(this.runtimeEnvironment, "migration", "openclaw.sqlite");
1669
1915
  let sourceCandidate;
@@ -1700,11 +1946,18 @@ var CronAutomationService = class extends Service {
1700
1946
  this.owner.logger("dsh-cron-automation").warn("skipped %s invalid OpenClaw cron job(s) from %s: %s", result.skipped.length, sqlitePath, details);
1701
1947
  }
1702
1948
  const incoming = result.jobs;
1949
+ const source = explicit ? openClawImportSource(sqlitePath) : automaticOpenClawImportSource();
1950
+ if (result.complete) {
1951
+ const removed = await this.manager.reconcileImportedDeletions(source, new Set(incoming.map((job) => job.id)));
1952
+ if (removed.length > 0) {
1953
+ this.owner.logger("dsh-cron-automation").info("removed %s imported cron job(s) deleted in OpenClaw", removed.length);
1954
+ }
1955
+ }
1703
1956
  if (incoming.length === 0) {
1704
1957
  this.owner.logger("dsh-cron-automation").info("automatic OpenClaw cron migration found no importable jobs in %s", sqlitePath);
1705
1958
  return;
1706
1959
  }
1707
- const added = await this.manager.importFrom(explicit ? openClawImportSource(sqlitePath) : automaticOpenClawImportSource(), incoming);
1960
+ const added = await this.manager.importFrom(source, incoming);
1708
1961
  this.owner.logger("dsh-cron-automation").info("automatic OpenClaw cron migration completed from %s: %s imported, %s already recorded", sqlitePath, added.length, incoming.length - added.length);
1709
1962
  }
1710
1963
  async stop() {
@@ -1751,6 +2004,7 @@ var CronAutomationService = class extends Service {
1751
2004
  };
1752
2005
  }
1753
2006
  async handleHttp(req, res) {
2007
+ await this.start();
1754
2008
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
1755
2009
  if (pathname === API_PREFIX && req.method === "GET") {
1756
2010
  sendJson(res, 200, await this.snapshot("subscribe"));
@@ -1830,6 +2084,7 @@ var CronAutomationService = class extends Service {
1830
2084
  return;
1831
2085
  this.pushQueued = true;
1832
2086
  this.pushTail = this.pushTail.then(async () => {
2087
+ await this.start();
1833
2088
  while (this.pendingPushReason) {
1834
2089
  const pendingReason = this.pendingPushReason;
1835
2090
  this.pendingPushReason = void 0;