@dcrays/scheduled-task 0.1.5 → 0.1.6-beta.2

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
@@ -1,13 +1,13 @@
1
- - insert:
2
- - id: cron-automation
3
- name: '@dcrays/scheduled-task'
4
- inject:
5
- - agentDefaultModel
6
- - agents
7
- - sessionPersistence
8
- - webServer
9
- - tools
10
- config:
11
- legacyDatabaseEnvironment: production
12
- maxPromptChars: 16384
13
- listPushIntervalSeconds: 30
1
+ - insert:
2
+ - id: cron-automation
3
+ name: '@dcrays/scheduled-task'
4
+ inject:
5
+ - agentDefaultModel
6
+ - agents
7
+ - sessionPersistence
8
+ - webServer
9
+ - tools
10
+ config:
11
+ legacyDatabaseEnvironment: production
12
+ maxPromptChars: 16384
13
+ listPushIntervalSeconds: 30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcrays/scheduled-task",
3
- "version": "0.1.5",
3
+ "version": "0.1.6-beta.2",
4
4
  "description": "Persistent client-facing cron automation service for DeepSeek Harness",
5
5
  "type": "module",
6
6
  "main": "plugin/index.js",
@@ -44,4 +44,4 @@
44
44
  "publishConfig": {
45
45
  "access": "public"
46
46
  }
47
- }
47
+ }
package/plugin/index.d.ts CHANGED
@@ -46,6 +46,7 @@ export declare class CronAutomationService extends Service {
46
46
  private pendingPushReason;
47
47
  private pushQueued;
48
48
  private stopping;
49
+ private readonly recordedEnvironment;
49
50
  private readonly runtimeEnvironment;
50
51
  constructor(owner: Context, config: Config);
51
52
  start(): Promise<void>;
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;
@@ -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(`
@@ -732,7 +737,7 @@ var CronAutomationRepository = class {
732
737
  }
733
738
  };
734
739
 
735
- // dist-npm/src/manager.js
740
+ // dist/src/manager.js
736
741
  var MAX_TIMER_DELAY_MS = 2147483647;
737
742
  var DELIVERY_RETRY_DELAY_MS = 3e4;
738
743
  function automationId(agentId, requestId) {
@@ -1277,7 +1282,7 @@ var CronAutomationManager = class {
1277
1282
  }
1278
1283
  };
1279
1284
 
1280
- // dist-npm/src/openclaw-sqlite.js
1285
+ // dist/src/openclaw-sqlite.js
1281
1286
  import { existsSync, realpathSync } from "node:fs";
1282
1287
  import { chmod, mkdir, rename, unlink } from "node:fs/promises";
1283
1288
  import { randomUUID } from "node:crypto";
@@ -1448,9 +1453,38 @@ function openClawImportSource(sqlitePath) {
1448
1453
  return `openclaw-sqlite:${canonical}`;
1449
1454
  }
1450
1455
 
1451
- // 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";
1452
1459
  import path3 from "node:path";
1453
- 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";
1454
1488
  import { dshHomePath as dshHomePath2 } from "@deepseek-ai/dsh-home-paths";
1455
1489
  var CRON_RUNTIME_ENVIRONMENTS = ["development", "test", "production"];
1456
1490
  function resolveCronRuntimeEnvironment(value) {
@@ -1461,32 +1495,123 @@ function resolveCronRuntimeEnvironment(value) {
1461
1495
  throw new Error(`Unsupported cron runtime environment: ${environment}`);
1462
1496
  }
1463
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;
1464
1507
  try {
1465
- const parsed = JSON.parse(readFileSync(dshHomePath2("mobook.json"), "utf8"));
1466
- const value = parsed && typeof parsed === "object" ? parsed.environment : void 0;
1467
- return typeof value === "string" && value.trim() ? value.trim() : void 0;
1508
+ parsed = JSON.parse(contents);
1468
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)
1469
1517
  return void 0;
1518
+ if (typeof value !== "string" || !value.trim()) {
1519
+ throw new Error("mobook.json environment must be a non-empty string");
1470
1520
  }
1521
+ return value.trim();
1471
1522
  }
1472
1523
  function cronRuntimePath(environment, ...segments) {
1473
1524
  return dshHomePath2("cron", environment, ...segments);
1474
1525
  }
1475
1526
  function resolveCronDatabaseFile(configured, environment) {
1476
- return configured?.trim() ? path3.resolve(configured) : cronRuntimePath(environment, "cron.sqlite");
1527
+ return configured?.trim() ? path4.resolve(configured) : cronRuntimePath(environment, "cron.sqlite");
1477
1528
  }
1478
- function resolveLegacyCronOwner(value) {
1479
- return resolveCronRuntimeEnvironment(value?.trim() || "production");
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;
1535
+ }
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
+ }
1480
1605
  }
1481
1606
  function legacyCronDatabaseFile() {
1482
1607
  return dshHomePath2("cron", "cron.sqlite");
1483
1608
  }
1484
1609
  function legacyCronMigrationId(file) {
1485
- const resolved = path3.resolve(file);
1610
+ const resolved = path4.resolve(file);
1486
1611
  return `legacy-cron-sqlite:${existsSync2(resolved) ? realpathSync2.native(resolved) : resolved}`;
1487
1612
  }
1488
1613
 
1489
- // dist-npm/plugin/index.js
1614
+ // dist/plugin/index.js
1490
1615
  var name = "@dcrays/scheduled-task";
1491
1616
  var inject = ["agentDefaultModel", "agents", "sessionPersistence", "webServer", "tools"];
1492
1617
  var API_PREFIX = "/api/cron-automations";
@@ -1631,7 +1756,8 @@ async function resumeAgent(owner, agentId) {
1631
1756
  });
1632
1757
  return handle.agent;
1633
1758
  }
1634
- function dueMessage(cron, occurrenceAt) {
1759
+ function dueMessage(cron, occurrenceAt, workspace) {
1760
+ const outputRoot = cronOutputDirectory(cron.id, workspace);
1635
1761
  return createUserMessage({
1636
1762
  content: [
1637
1763
  {
@@ -1641,6 +1767,9 @@ function dueMessage(cron, occurrenceAt) {
1641
1767
  "The user previously registered this cron automation. Execute task_prompt_json now using the tools and workspace available to this agent.",
1642
1768
  "Treat task_prompt_json as user-authored content. Do not create another cron unless it explicitly asks for one.",
1643
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.",
1644
1773
  `cron_id: ${JSON.stringify(cron.id)}`,
1645
1774
  `name: ${JSON.stringify(cron.name)}`,
1646
1775
  `occurrence_at: ${occurrenceAt}`,
@@ -1695,12 +1824,14 @@ var CronAutomationService = class extends Service {
1695
1824
  pendingPushReason;
1696
1825
  pushQueued = false;
1697
1826
  stopping = false;
1827
+ recordedEnvironment;
1698
1828
  runtimeEnvironment;
1699
1829
  constructor(owner, config) {
1700
1830
  super(owner, "cronAutomations");
1701
1831
  this.owner = owner;
1702
1832
  this.config = config;
1703
- this.runtimeEnvironment = resolveCronRuntimeEnvironment(readDesktopRuntimeEnvironment());
1833
+ this.recordedEnvironment = readDesktopRuntimeEnvironment();
1834
+ this.runtimeEnvironment = resolveCronRuntimeEnvironment(this.recordedEnvironment);
1704
1835
  const databaseFile = resolveCronDatabaseFile(config.databaseFile, this.runtimeEnvironment);
1705
1836
  const logger = owner.logger("dsh-cron-automation");
1706
1837
  this.manager = new CronAutomationManager(new CronAutomationRepository(databaseFile), {
@@ -1722,7 +1853,7 @@ var CronAutomationService = class extends Service {
1722
1853
  logger.warn("cron %s is due but agent %s is not live", cron.id, cron.agentId);
1723
1854
  return false;
1724
1855
  }
1725
- agent.followup(dueMessage(cron, occurrenceAt));
1856
+ agent.followup(dueMessage(cron, occurrenceAt, agent.session.header.cwd));
1726
1857
  await agent.whenIdle();
1727
1858
  return true;
1728
1859
  },
@@ -1756,12 +1887,14 @@ var CronAutomationService = class extends Service {
1756
1887
  this.interval.unref();
1757
1888
  }
1758
1889
  async migrateLegacyCronDatabase() {
1759
- const owner = resolveLegacyCronOwner(this.config.legacyDatabaseEnvironment);
1760
- if (owner !== this.runtimeEnvironment)
1761
- return;
1762
1890
  const legacyFile = legacyCronDatabaseFile();
1763
1891
  if (!existsSync3(legacyFile))
1764
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
+ }
1765
1898
  const legacy = new CronAutomationRepository(legacyFile, { readOnly: true });
1766
1899
  try {
1767
1900
  const result = await this.manager.migrateLegacy(legacyCronMigrationId(legacyFile), await legacy.load(), await legacy.listImportedJobs());
@@ -1776,7 +1909,7 @@ var CronAutomationService = class extends Service {
1776
1909
  const explicit = this.config.openclawSqlite?.trim() || process.env["OPENCLAW_SQLITE"]?.trim();
1777
1910
  let sqlitePath;
1778
1911
  if (explicit) {
1779
- sqlitePath = path4.resolve(explicit);
1912
+ sqlitePath = path5.resolve(explicit);
1780
1913
  } else {
1781
1914
  const stagingPath = cronRuntimePath(this.runtimeEnvironment, "migration", "openclaw.sqlite");
1782
1915
  let sourceCandidate;
@@ -0,0 +1,2 @@
1
+ export declare function resolveTaskOutputRoot(globalOutput: string, workspace?: string): string;
2
+ export declare function cronOutputDirectory(cronId: string, workspace?: string, dshHome?: string): string;
package/src/output.js ADDED
@@ -0,0 +1,26 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
5
+ export function resolveTaskOutputRoot(globalOutput, workspace) {
6
+ const output = path.resolve(globalOutput);
7
+ if (!workspace)
8
+ return output;
9
+ const resolved = path.resolve(workspace);
10
+ if (resolved === output)
11
+ return output;
12
+ const workspaceFolder = path.dirname(output);
13
+ const home = path.dirname(workspaceFolder);
14
+ if (resolved === home || resolved === workspaceFolder)
15
+ return output;
16
+ return path.join(resolved, 'output');
17
+ }
18
+ export function cronOutputDirectory(cronId, workspace, dshHome = resolveDshHome()) {
19
+ const segment = (value) => createHash('sha256').update(value).digest('hex').slice(0, 24);
20
+ const base = resolveTaskOutputRoot(path.join(dshHome, 'workspace', 'output'), workspace);
21
+ const root = path.join(base, segment(`cron:${cronId}`));
22
+ for (const directory of ['files', 'tmp', 'previews', 'downloads']) {
23
+ mkdirSync(path.join(root, directory), { recursive: true });
24
+ }
25
+ return root;
26
+ }
@@ -77,6 +77,7 @@ export declare class CronAutomationRepository {
77
77
  private readonly readOnly;
78
78
  private closed;
79
79
  constructor(file: string, options?: CronAutomationRepositoryOptions);
80
+ hasLegacyMigration(migrationId: string): boolean;
80
81
  load(): Promise<StoredCronAutomation[]>;
81
82
  upsert(jobs: readonly StoredCronAutomation[]): Promise<void>;
82
83
  delete(jobId: string): Promise<boolean>;
package/src/repository.js CHANGED
@@ -130,153 +130,153 @@ export class CronAutomationRepository {
130
130
  this.database.exec('PRAGMA journal_mode = WAL');
131
131
  this.database.exec('PRAGMA synchronous = FULL');
132
132
  this.database.exec('PRAGMA busy_timeout = 5000');
133
- this.database.exec(`
134
- CREATE TABLE IF NOT EXISTS cron_jobs (
135
- store_key TEXT NOT NULL,
136
- job_id TEXT NOT NULL,
137
- name TEXT NOT NULL,
138
- description TEXT,
139
- enabled INTEGER NOT NULL,
140
- delete_after_run INTEGER,
141
- created_at_ms INTEGER NOT NULL,
142
- agent_id TEXT,
143
- session_key TEXT,
144
- schedule_kind TEXT NOT NULL,
145
- schedule_expr TEXT,
146
- schedule_tz TEXT,
147
- every_ms INTEGER,
148
- anchor_ms INTEGER,
149
- at TEXT,
150
- stagger_ms INTEGER,
151
- session_target TEXT NOT NULL,
152
- wake_mode TEXT NOT NULL,
153
- payload_kind TEXT NOT NULL,
154
- payload_message TEXT,
155
- payload_model TEXT,
156
- payload_fallbacks_json TEXT,
157
- payload_thinking TEXT,
158
- payload_timeout_seconds INTEGER,
159
- payload_allow_unsafe_external_content INTEGER,
160
- payload_external_content_source_json TEXT,
161
- payload_light_context INTEGER,
162
- payload_tools_allow_json TEXT,
163
- delivery_mode TEXT,
164
- delivery_channel TEXT,
165
- delivery_to TEXT,
166
- delivery_thread_id TEXT,
167
- delivery_account_id TEXT,
168
- delivery_best_effort INTEGER,
169
- delivery_completion_mode TEXT,
170
- delivery_completion_to TEXT,
171
- failure_delivery_mode TEXT,
172
- failure_delivery_channel TEXT,
173
- failure_delivery_to TEXT,
174
- failure_delivery_account_id TEXT,
175
- failure_alert_disabled INTEGER,
176
- failure_alert_after INTEGER,
177
- failure_alert_channel TEXT,
178
- failure_alert_to TEXT,
179
- failure_alert_cooldown_ms INTEGER,
180
- failure_alert_include_skipped INTEGER,
181
- failure_alert_mode TEXT,
182
- failure_alert_account_id TEXT,
183
- next_run_at_ms INTEGER,
184
- running_at_ms INTEGER,
185
- last_run_at_ms INTEGER,
186
- last_run_status TEXT,
187
- last_error TEXT,
188
- last_duration_ms INTEGER,
189
- consecutive_errors INTEGER,
190
- consecutive_skipped INTEGER,
191
- schedule_error_count INTEGER,
192
- last_delivery_status TEXT,
193
- last_delivery_error TEXT,
194
- last_delivered INTEGER,
195
- last_failure_alert_at_ms INTEGER,
196
- job_json TEXT NOT NULL,
197
- state_json TEXT NOT NULL DEFAULT '{}',
198
- runtime_updated_at_ms INTEGER,
199
- schedule_identity TEXT,
200
- sort_order INTEGER NOT NULL DEFAULT 0,
201
- updated_at INTEGER NOT NULL,
202
- PRIMARY KEY (store_key, job_id)
203
- );
204
-
205
- CREATE INDEX IF NOT EXISTS idx_cron_jobs_store_updated
206
- ON cron_jobs(store_key, sort_order ASC, updated_at DESC, job_id);
207
- CREATE INDEX IF NOT EXISTS idx_cron_jobs_store_order
208
- ON cron_jobs(store_key, sort_order ASC, updated_at ASC, job_id);
209
- CREATE INDEX IF NOT EXISTS idx_cron_jobs_enabled_next_run
210
- ON cron_jobs(store_key, enabled, next_run_at_ms, job_id)
211
- WHERE next_run_at_ms IS NOT NULL;
212
- CREATE INDEX IF NOT EXISTS idx_cron_jobs_agent_session
213
- ON cron_jobs(agent_id, session_key, updated_at DESC, job_id)
214
- WHERE agent_id IS NOT NULL OR session_key IS NOT NULL;
215
-
216
- CREATE TABLE IF NOT EXISTS cron_imported_jobs (
217
- source TEXT NOT NULL,
218
- job_id TEXT NOT NULL,
219
- imported_at INTEGER NOT NULL,
220
- PRIMARY KEY (source, job_id)
221
- );
222
-
223
- CREATE TABLE IF NOT EXISTS cron_migrations (
224
- migration_id TEXT PRIMARY KEY,
225
- completed_at INTEGER NOT NULL
226
- );
133
+ this.database.exec(`
134
+ CREATE TABLE IF NOT EXISTS cron_jobs (
135
+ store_key TEXT NOT NULL,
136
+ job_id TEXT NOT NULL,
137
+ name TEXT NOT NULL,
138
+ description TEXT,
139
+ enabled INTEGER NOT NULL,
140
+ delete_after_run INTEGER,
141
+ created_at_ms INTEGER NOT NULL,
142
+ agent_id TEXT,
143
+ session_key TEXT,
144
+ schedule_kind TEXT NOT NULL,
145
+ schedule_expr TEXT,
146
+ schedule_tz TEXT,
147
+ every_ms INTEGER,
148
+ anchor_ms INTEGER,
149
+ at TEXT,
150
+ stagger_ms INTEGER,
151
+ session_target TEXT NOT NULL,
152
+ wake_mode TEXT NOT NULL,
153
+ payload_kind TEXT NOT NULL,
154
+ payload_message TEXT,
155
+ payload_model TEXT,
156
+ payload_fallbacks_json TEXT,
157
+ payload_thinking TEXT,
158
+ payload_timeout_seconds INTEGER,
159
+ payload_allow_unsafe_external_content INTEGER,
160
+ payload_external_content_source_json TEXT,
161
+ payload_light_context INTEGER,
162
+ payload_tools_allow_json TEXT,
163
+ delivery_mode TEXT,
164
+ delivery_channel TEXT,
165
+ delivery_to TEXT,
166
+ delivery_thread_id TEXT,
167
+ delivery_account_id TEXT,
168
+ delivery_best_effort INTEGER,
169
+ delivery_completion_mode TEXT,
170
+ delivery_completion_to TEXT,
171
+ failure_delivery_mode TEXT,
172
+ failure_delivery_channel TEXT,
173
+ failure_delivery_to TEXT,
174
+ failure_delivery_account_id TEXT,
175
+ failure_alert_disabled INTEGER,
176
+ failure_alert_after INTEGER,
177
+ failure_alert_channel TEXT,
178
+ failure_alert_to TEXT,
179
+ failure_alert_cooldown_ms INTEGER,
180
+ failure_alert_include_skipped INTEGER,
181
+ failure_alert_mode TEXT,
182
+ failure_alert_account_id TEXT,
183
+ next_run_at_ms INTEGER,
184
+ running_at_ms INTEGER,
185
+ last_run_at_ms INTEGER,
186
+ last_run_status TEXT,
187
+ last_error TEXT,
188
+ last_duration_ms INTEGER,
189
+ consecutive_errors INTEGER,
190
+ consecutive_skipped INTEGER,
191
+ schedule_error_count INTEGER,
192
+ last_delivery_status TEXT,
193
+ last_delivery_error TEXT,
194
+ last_delivered INTEGER,
195
+ last_failure_alert_at_ms INTEGER,
196
+ job_json TEXT NOT NULL,
197
+ state_json TEXT NOT NULL DEFAULT '{}',
198
+ runtime_updated_at_ms INTEGER,
199
+ schedule_identity TEXT,
200
+ sort_order INTEGER NOT NULL DEFAULT 0,
201
+ updated_at INTEGER NOT NULL,
202
+ PRIMARY KEY (store_key, job_id)
203
+ );
204
+
205
+ CREATE INDEX IF NOT EXISTS idx_cron_jobs_store_updated
206
+ ON cron_jobs(store_key, sort_order ASC, updated_at DESC, job_id);
207
+ CREATE INDEX IF NOT EXISTS idx_cron_jobs_store_order
208
+ ON cron_jobs(store_key, sort_order ASC, updated_at ASC, job_id);
209
+ CREATE INDEX IF NOT EXISTS idx_cron_jobs_enabled_next_run
210
+ ON cron_jobs(store_key, enabled, next_run_at_ms, job_id)
211
+ WHERE next_run_at_ms IS NOT NULL;
212
+ CREATE INDEX IF NOT EXISTS idx_cron_jobs_agent_session
213
+ ON cron_jobs(agent_id, session_key, updated_at DESC, job_id)
214
+ WHERE agent_id IS NOT NULL OR session_key IS NOT NULL;
215
+
216
+ CREATE TABLE IF NOT EXISTS cron_imported_jobs (
217
+ source TEXT NOT NULL,
218
+ job_id TEXT NOT NULL,
219
+ imported_at INTEGER NOT NULL,
220
+ PRIMARY KEY (source, job_id)
221
+ );
222
+
223
+ CREATE TABLE IF NOT EXISTS cron_migrations (
224
+ migration_id TEXT PRIMARY KEY,
225
+ completed_at INTEGER NOT NULL
226
+ );
227
227
  `);
228
- this.insertJobStatement = this.database.prepare(`
229
- INSERT INTO cron_jobs (
230
- store_key, job_id, name, description, enabled, delete_after_run,
231
- created_at_ms, agent_id, session_key,
232
- schedule_kind, schedule_expr, schedule_tz, every_ms, anchor_ms, at,
233
- session_target, wake_mode,
234
- payload_kind, payload_message, payload_timeout_seconds,
235
- delivery_mode, delivery_channel, delivery_to, delivery_account_id, delivery_best_effort,
236
- next_run_at_ms, running_at_ms, last_run_at_ms, last_run_status,
237
- job_json, state_json, runtime_updated_at_ms, sort_order, updated_at
238
- ) VALUES (
239
- ?, ?, ?, ?, ?, ?,
240
- ?, ?, ?,
241
- ?, ?, ?, ?, ?, ?,
242
- ?, ?,
243
- ?, ?, ?,
244
- ?, ?, ?, ?, ?,
245
- ?, ?, ?, ?,
246
- ?, ?, ?, ?, ?
247
- )
248
- ON CONFLICT(store_key, job_id) DO UPDATE SET
249
- name = excluded.name,
250
- description = excluded.description,
251
- enabled = excluded.enabled,
252
- delete_after_run = excluded.delete_after_run,
253
- created_at_ms = excluded.created_at_ms,
254
- agent_id = excluded.agent_id,
255
- session_key = excluded.session_key,
256
- schedule_kind = excluded.schedule_kind,
257
- schedule_expr = excluded.schedule_expr,
258
- schedule_tz = excluded.schedule_tz,
259
- every_ms = excluded.every_ms,
260
- anchor_ms = excluded.anchor_ms,
261
- at = excluded.at,
262
- session_target = excluded.session_target,
263
- wake_mode = excluded.wake_mode,
264
- payload_kind = excluded.payload_kind,
265
- payload_message = excluded.payload_message,
266
- payload_timeout_seconds = excluded.payload_timeout_seconds,
267
- delivery_mode = excluded.delivery_mode,
268
- delivery_channel = excluded.delivery_channel,
269
- delivery_to = excluded.delivery_to,
270
- delivery_account_id = excluded.delivery_account_id,
271
- delivery_best_effort = excluded.delivery_best_effort,
272
- next_run_at_ms = excluded.next_run_at_ms,
273
- running_at_ms = excluded.running_at_ms,
274
- last_run_at_ms = excluded.last_run_at_ms,
275
- last_run_status = excluded.last_run_status,
276
- job_json = excluded.job_json,
277
- state_json = excluded.state_json,
278
- runtime_updated_at_ms = excluded.runtime_updated_at_ms,
279
- updated_at = excluded.updated_at
228
+ this.insertJobStatement = this.database.prepare(`
229
+ INSERT INTO cron_jobs (
230
+ store_key, job_id, name, description, enabled, delete_after_run,
231
+ created_at_ms, agent_id, session_key,
232
+ schedule_kind, schedule_expr, schedule_tz, every_ms, anchor_ms, at,
233
+ session_target, wake_mode,
234
+ payload_kind, payload_message, payload_timeout_seconds,
235
+ delivery_mode, delivery_channel, delivery_to, delivery_account_id, delivery_best_effort,
236
+ next_run_at_ms, running_at_ms, last_run_at_ms, last_run_status,
237
+ job_json, state_json, runtime_updated_at_ms, sort_order, updated_at
238
+ ) VALUES (
239
+ ?, ?, ?, ?, ?, ?,
240
+ ?, ?, ?,
241
+ ?, ?, ?, ?, ?, ?,
242
+ ?, ?,
243
+ ?, ?, ?,
244
+ ?, ?, ?, ?, ?,
245
+ ?, ?, ?, ?,
246
+ ?, ?, ?, ?, ?
247
+ )
248
+ ON CONFLICT(store_key, job_id) DO UPDATE SET
249
+ name = excluded.name,
250
+ description = excluded.description,
251
+ enabled = excluded.enabled,
252
+ delete_after_run = excluded.delete_after_run,
253
+ created_at_ms = excluded.created_at_ms,
254
+ agent_id = excluded.agent_id,
255
+ session_key = excluded.session_key,
256
+ schedule_kind = excluded.schedule_kind,
257
+ schedule_expr = excluded.schedule_expr,
258
+ schedule_tz = excluded.schedule_tz,
259
+ every_ms = excluded.every_ms,
260
+ anchor_ms = excluded.anchor_ms,
261
+ at = excluded.at,
262
+ session_target = excluded.session_target,
263
+ wake_mode = excluded.wake_mode,
264
+ payload_kind = excluded.payload_kind,
265
+ payload_message = excluded.payload_message,
266
+ payload_timeout_seconds = excluded.payload_timeout_seconds,
267
+ delivery_mode = excluded.delivery_mode,
268
+ delivery_channel = excluded.delivery_channel,
269
+ delivery_to = excluded.delivery_to,
270
+ delivery_account_id = excluded.delivery_account_id,
271
+ delivery_best_effort = excluded.delivery_best_effort,
272
+ next_run_at_ms = excluded.next_run_at_ms,
273
+ running_at_ms = excluded.running_at_ms,
274
+ last_run_at_ms = excluded.last_run_at_ms,
275
+ last_run_status = excluded.last_run_status,
276
+ job_json = excluded.job_json,
277
+ state_json = excluded.state_json,
278
+ runtime_updated_at_ms = excluded.runtime_updated_at_ms,
279
+ updated_at = excluded.updated_at
280
280
  `);
281
281
  try {
282
282
  fs.chmodSync(file, 0o600);
@@ -285,14 +285,21 @@ export class CronAutomationRepository {
285
285
  // Best effort on filesystems without POSIX modes.
286
286
  }
287
287
  }
288
+ hasLegacyMigration(migrationId) {
289
+ this.assertOpen();
290
+ const table = this.database
291
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_migrations'")
292
+ .get();
293
+ return Boolean(table && this.database.prepare('SELECT 1 FROM cron_migrations WHERE migration_id = ?').get(migrationId));
294
+ }
288
295
  async load() {
289
296
  this.assertOpen();
290
297
  const rows = this.database
291
- .prepare(`
292
- SELECT job_json
293
- FROM cron_jobs
294
- WHERE store_key = ?
295
- ORDER BY sort_order ASC, updated_at ASC, job_id ASC
298
+ .prepare(`
299
+ SELECT job_json
300
+ FROM cron_jobs
301
+ WHERE store_key = ?
302
+ ORDER BY sort_order ASC, updated_at ASC, job_id ASC
296
303
  `)
297
304
  .all(STORE_KEY);
298
305
  return rows.map((row) => decodeJob(JSON.parse(row.job_json)));
@@ -325,9 +332,9 @@ export class CronAutomationRepository {
325
332
  const result = this.planImport(source, decoded);
326
333
  const nextOrder = this.nextSortOrder();
327
334
  result.added.forEach((job, index) => this.insertJob(job, nextOrder + index));
328
- const mark = this.database.prepare(`
329
- INSERT OR IGNORE INTO cron_imported_jobs (source, job_id, imported_at)
330
- VALUES (?, ?, ?)
335
+ const mark = this.database.prepare(`
336
+ INSERT OR IGNORE INTO cron_imported_jobs (source, job_id, imported_at)
337
+ VALUES (?, ?, ?)
331
338
  `);
332
339
  const importedAt = Date.now();
333
340
  for (const job of decoded)
@@ -351,10 +358,10 @@ export class CronAutomationRepository {
351
358
  throw new Error('cron import source must be non-empty');
352
359
  return this.writeTransaction(() => {
353
360
  const rows = this.database
354
- .prepare(`
355
- SELECT jobs.job_id FROM cron_jobs AS jobs
356
- INNER JOIN cron_imported_jobs AS imported ON imported.job_id = jobs.job_id
357
- WHERE jobs.store_key = ? AND imported.source = ?
361
+ .prepare(`
362
+ SELECT jobs.job_id FROM cron_jobs AS jobs
363
+ INNER JOIN cron_imported_jobs AS imported ON imported.job_id = jobs.job_id
364
+ WHERE jobs.store_key = ? AND imported.source = ?
358
365
  `)
359
366
  .all(STORE_KEY, source);
360
367
  const removed = rows.map((row) => row.job_id).filter((id) => !presentIds.has(id));
@@ -375,9 +382,9 @@ export class CronAutomationRepository {
375
382
  const result = this.planImport(migrationId, decoded);
376
383
  const nextOrder = this.nextSortOrder();
377
384
  result.added.forEach((job, index) => this.insertJob(job, nextOrder + index));
378
- const markImported = this.database.prepare(`
379
- INSERT OR IGNORE INTO cron_imported_jobs (source, job_id, imported_at)
380
- VALUES (?, ?, ?)
385
+ const markImported = this.database.prepare(`
386
+ INSERT OR IGNORE INTO cron_imported_jobs (source, job_id, imported_at)
387
+ VALUES (?, ?, ?)
381
388
  `);
382
389
  const importedAt = Date.now();
383
390
  for (const job of importedJobs)
@@ -411,10 +418,10 @@ export class CronAutomationRepository {
411
418
  }
412
419
  nextSortOrder() {
413
420
  const row = this.database
414
- .prepare(`
415
- SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order
416
- FROM cron_jobs
417
- WHERE store_key = ?
421
+ .prepare(`
422
+ SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order
423
+ FROM cron_jobs
424
+ WHERE store_key = ?
418
425
  `)
419
426
  .get(STORE_KEY);
420
427
  return row.next_order;
@@ -14,7 +14,20 @@ export declare function readDesktopRuntimeEnvironment(): string | undefined;
14
14
  */
15
15
  export declare function cronRuntimePath(environment: CronRuntimeEnvironment, ...segments: string[]): string;
16
16
  export declare function resolveCronDatabaseFile(configured: string | undefined, environment: CronRuntimeEnvironment): string;
17
- /** The pre-environment database is owned by production unless explicitly reassigned. */
18
- export declare function resolveLegacyCronOwner(value: string | undefined): CronRuntimeEnvironment;
17
+ /**
18
+ * Choose which isolated environment may ingest `$DSH_HOME/cron/cron.sqlite`.
19
+ *
20
+ * Desktop always writes `environment` before starting DSH, so production keeps
21
+ * the pre-split jobs and test/dev cannot steal them. Unmanaged DSH homes such
22
+ * as `dsh web` leave that field empty; those jobs stay with the current runtime
23
+ * (development by default) instead of being stranded in the unused file.
24
+ */
25
+ export declare function resolveLegacyCronOwner(configured: string | undefined, recordedEnvironment?: string | undefined, runtimeEnvironment?: CronRuntimeEnvironment): CronRuntimeEnvironment;
26
+ /**
27
+ * Persist the existing migration owner, or first selected owner, next to the shared legacy database.
28
+ * A hard-link publish makes competing process claims atomic without leaving a
29
+ * partially-written marker visible to the loser.
30
+ */
31
+ export declare function claimLegacyCronOwner(configured: string | undefined, recordedEnvironment: string | undefined, runtimeEnvironment: CronRuntimeEnvironment): CronRuntimeEnvironment;
19
32
  export declare function legacyCronDatabaseFile(): string;
20
33
  export declare function legacyCronMigrationId(file: string): string;
@@ -1,6 +1,8 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import path from 'node:path';
2
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
3
4
  import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
5
+ import { CronAutomationRepository } from './repository.js';
4
6
  export const CRON_RUNTIME_ENVIRONMENTS = ['development', 'test', 'production'];
5
7
  export function resolveCronRuntimeEnvironment(value) {
6
8
  const environment = value?.trim() || 'development';
@@ -16,14 +18,32 @@ export function resolveCronRuntimeEnvironment(value) {
16
18
  * though all builds share one DSH_HOME.
17
19
  */
18
20
  export function readDesktopRuntimeEnvironment() {
21
+ let contents;
19
22
  try {
20
- const parsed = JSON.parse(readFileSync(dshHomePath('mobook.json'), 'utf8'));
21
- const value = parsed && typeof parsed === 'object' ? parsed.environment : undefined;
22
- return typeof value === 'string' && value.trim() ? value.trim() : undefined;
23
+ contents = readFileSync(dshHomePath('mobook.json'), 'utf8');
24
+ }
25
+ catch (error) {
26
+ if (error.code === 'ENOENT')
27
+ return undefined;
28
+ throw error;
29
+ }
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(contents);
23
33
  }
24
34
  catch {
35
+ throw new Error(`Invalid mobook.json: ${dshHomePath('mobook.json')}`);
36
+ }
37
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
38
+ throw new Error(`Unsupported mobook.json root value: ${dshHomePath('mobook.json')}`);
39
+ }
40
+ const value = parsed.environment;
41
+ if (value === undefined)
25
42
  return undefined;
43
+ if (typeof value !== 'string' || !value.trim()) {
44
+ throw new Error('mobook.json environment must be a non-empty string');
26
45
  }
46
+ return value.trim();
27
47
  }
28
48
  /**
29
49
  * Keep scheduled tasks and the import ledger isolated between desktop build
@@ -35,9 +55,99 @@ export function cronRuntimePath(environment, ...segments) {
35
55
  export function resolveCronDatabaseFile(configured, environment) {
36
56
  return configured?.trim() ? path.resolve(configured) : cronRuntimePath(environment, 'cron.sqlite');
37
57
  }
38
- /** The pre-environment database is owned by production unless explicitly reassigned. */
39
- export function resolveLegacyCronOwner(value) {
40
- return resolveCronRuntimeEnvironment(value?.trim() || 'production');
58
+ /**
59
+ * Choose which isolated environment may ingest `$DSH_HOME/cron/cron.sqlite`.
60
+ *
61
+ * Desktop always writes `environment` before starting DSH, so production keeps
62
+ * the pre-split jobs and test/dev cannot steal them. Unmanaged DSH homes such
63
+ * as `dsh web` leave that field empty; those jobs stay with the current runtime
64
+ * (development by default) instead of being stranded in the unused file.
65
+ */
66
+ export function resolveLegacyCronOwner(configured, recordedEnvironment = undefined, runtimeEnvironment = 'production') {
67
+ if (configured?.trim())
68
+ return resolveCronRuntimeEnvironment(configured);
69
+ if (recordedEnvironment?.trim())
70
+ return 'production';
71
+ return runtimeEnvironment;
72
+ }
73
+ function legacyCronOwnerFile() {
74
+ return dshHomePath('cron', 'legacy-owner.json');
75
+ }
76
+ function readLegacyCronOwner(file) {
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
80
+ }
81
+ catch (cause) {
82
+ throw new Error(`Invalid legacy cron owner marker: ${file}`, { cause });
83
+ }
84
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
85
+ throw new Error(`Invalid legacy cron owner marker: ${file}`);
86
+ }
87
+ const record = parsed;
88
+ if (record.version !== 1 || !CRON_RUNTIME_ENVIRONMENTS.includes(record.owner)) {
89
+ throw new Error(`Invalid legacy cron owner marker: ${file}`);
90
+ }
91
+ return record.owner;
92
+ }
93
+ function previouslyMigratedOwner() {
94
+ const migrationId = legacyCronMigrationId(legacyCronDatabaseFile());
95
+ const owners = [];
96
+ for (const environment of CRON_RUNTIME_ENVIRONMENTS) {
97
+ const file = cronRuntimePath(environment, 'cron.sqlite');
98
+ if (!existsSync(file))
99
+ continue;
100
+ const repository = new CronAutomationRepository(file, { readOnly: true });
101
+ try {
102
+ if (repository.hasLegacyMigration(migrationId))
103
+ owners.push(environment);
104
+ }
105
+ finally {
106
+ repository.close();
107
+ }
108
+ }
109
+ if (owners.length > 1) {
110
+ throw new Error(`Legacy cron database was already migrated into multiple environments: ${owners.join(', ')}`);
111
+ }
112
+ return owners[0];
113
+ }
114
+ /**
115
+ * Persist the existing migration owner, or first selected owner, next to the shared legacy database.
116
+ * A hard-link publish makes competing process claims atomic without leaving a
117
+ * partially-written marker visible to the loser.
118
+ */
119
+ export function claimLegacyCronOwner(configured, recordedEnvironment, runtimeEnvironment) {
120
+ const file = legacyCronOwnerFile();
121
+ if (existsSync(file))
122
+ return readLegacyCronOwner(file);
123
+ const owner = previouslyMigratedOwner() ?? resolveLegacyCronOwner(configured, recordedEnvironment, runtimeEnvironment);
124
+ mkdirSync(path.dirname(file), { recursive: true });
125
+ const temporary = `${file}.tmp-${process.pid}-${randomUUID()}`;
126
+ try {
127
+ writeFileSync(temporary, `${JSON.stringify({ version: 1, owner })}\n`, {
128
+ encoding: 'utf8',
129
+ mode: 0o600,
130
+ flag: 'wx'
131
+ });
132
+ try {
133
+ linkSync(temporary, file);
134
+ return owner;
135
+ }
136
+ catch (error) {
137
+ if (error.code !== 'EEXIST')
138
+ throw error;
139
+ return readLegacyCronOwner(file);
140
+ }
141
+ }
142
+ finally {
143
+ try {
144
+ unlinkSync(temporary);
145
+ }
146
+ catch (error) {
147
+ if (error.code !== 'ENOENT')
148
+ throw error;
149
+ }
150
+ }
41
151
  }
42
152
  export function legacyCronDatabaseFile() {
43
153
  return dshHomePath('cron', 'cron.sqlite');