@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 +13 -13
- package/package.json +2 -2
- package/plugin/index.d.ts +1 -0
- package/plugin/index.js +158 -25
- package/src/output.d.ts +2 -0
- package/src/output.js +26 -0
- package/src/repository.d.ts +1 -0
- package/src/repository.js +172 -165
- package/src/runtime-environment.d.ts +15 -2
- package/src/runtime-environment.js +117 -7
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.
|
|
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
|
|
3
|
+
// dist/plugin/index.js
|
|
4
4
|
import { existsSync as existsSync3 } from "node:fs";
|
|
5
|
-
import
|
|
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
|
|
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
|
|
211
|
+
// dist/src/manager.js
|
|
212
212
|
import { createHash } from "node:crypto";
|
|
213
213
|
|
|
214
|
-
// dist
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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() ?
|
|
1527
|
+
return configured?.trim() ? path4.resolve(configured) : cronRuntimePath(environment, "cron.sqlite");
|
|
1477
1528
|
}
|
|
1478
|
-
function resolveLegacyCronOwner(
|
|
1479
|
-
|
|
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 =
|
|
1610
|
+
const resolved = path4.resolve(file);
|
|
1486
1611
|
return `legacy-cron-sqlite:${existsSync2(resolved) ? realpathSync2.native(resolved) : resolved}`;
|
|
1487
1612
|
}
|
|
1488
1613
|
|
|
1489
|
-
// dist
|
|
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.
|
|
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 =
|
|
1912
|
+
sqlitePath = path5.resolve(explicit);
|
|
1780
1913
|
} else {
|
|
1781
1914
|
const stagingPath = cronRuntimePath(this.runtimeEnvironment, "migration", "openclaw.sqlite");
|
|
1782
1915
|
let sourceCandidate;
|
package/src/output.d.ts
ADDED
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
|
+
}
|
package/src/repository.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
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');
|