@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/cordis.patch.yml +0 -1
- package/package.json +2 -2
- package/plugin/index.d.ts +1 -1
- package/plugin/index.js +344 -89
- package/src/cron.js +3 -1
- package/src/manager.d.ts +14 -0
- package/src/manager.js +166 -64
- package/src/openclaw-sqlite.d.ts +2 -0
- package/src/openclaw-sqlite.js +3 -3
- package/src/output.d.ts +2 -0
- package/src/output.js +26 -0
- package/src/repository.d.ts +3 -0
- package/src/repository.js +28 -0
- package/src/runtime-environment.d.ts +15 -2
- package/src/runtime-environment.js +117 -7
package/src/cron.js
CHANGED
|
@@ -24,7 +24,9 @@ function parseInteger(value, label) {
|
|
|
24
24
|
}
|
|
25
25
|
function parseField(raw, min, max, label, normalize) {
|
|
26
26
|
const values = new Set();
|
|
27
|
-
|
|
27
|
+
// A stepped field such as */2 selects a subset of values. It must remain
|
|
28
|
+
// restricted for cron's day-of-month/day-of-week matching rule.
|
|
29
|
+
const wildcard = raw === "*";
|
|
28
30
|
if (raw.length === 0)
|
|
29
31
|
throw new CronExpressionError(`${label} is empty`);
|
|
30
32
|
for (const segment of raw.split(",")) {
|
package/src/manager.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { CronCreateEnvelope } from './protocol.js';
|
|
|
2
2
|
import { CronAutomationRepository, type CronImportedJob, type StoredCronAutomation } from './repository.js';
|
|
3
3
|
export interface CronAutomationManagerOptions {
|
|
4
4
|
maxPromptChars: number;
|
|
5
|
+
startPaused?: boolean;
|
|
5
6
|
now?: () => number;
|
|
6
7
|
deliver(job: StoredCronAutomation, occurrenceAt: string): boolean | Promise<boolean>;
|
|
7
8
|
onError?(error: unknown): void;
|
|
@@ -25,12 +26,18 @@ export declare class CronAutomationManager {
|
|
|
25
26
|
private readonly options;
|
|
26
27
|
private readonly jobs;
|
|
27
28
|
private readonly blockedAgents;
|
|
29
|
+
private readonly blockedUntil;
|
|
30
|
+
private readonly activeJobs;
|
|
31
|
+
private readonly inflight;
|
|
28
32
|
private readonly now;
|
|
29
33
|
private started?;
|
|
30
34
|
private tail;
|
|
35
|
+
private dispatchLoop;
|
|
31
36
|
private timer;
|
|
32
37
|
private stopping;
|
|
38
|
+
private schedulingPaused;
|
|
33
39
|
constructor(repository: CronAutomationRepository, options: CronAutomationManagerOptions);
|
|
40
|
+
resumeScheduling(): void;
|
|
34
41
|
start(): Promise<void>;
|
|
35
42
|
private initialize;
|
|
36
43
|
stop(): Promise<void>;
|
|
@@ -41,6 +48,8 @@ export declare class CronAutomationManager {
|
|
|
41
48
|
runOnce(jobId: string): Promise<boolean>;
|
|
42
49
|
list(agentId?: string): Promise<StoredCronAutomation[]>;
|
|
43
50
|
notifyAgentAvailable(agentId: string): void;
|
|
51
|
+
/** Reflect deletions only for ids recorded in this source's import ledger. */
|
|
52
|
+
reconcileImportedDeletions(source: string, presentIds: ReadonlySet<string>): Promise<string[]>;
|
|
44
53
|
/** Import each source job once. Existing ids win so DSH bindings stay intact. */
|
|
45
54
|
importFrom(source: string, incoming: readonly StoredCronAutomation[]): Promise<StoredCronAutomation[]>;
|
|
46
55
|
migrateLegacy(migrationId: string, incoming: readonly StoredCronAutomation[], importedJobs: readonly CronImportedJob[]): Promise<{
|
|
@@ -49,6 +58,11 @@ export declare class CronAutomationManager {
|
|
|
49
58
|
}>;
|
|
50
59
|
private prepareImportedJob;
|
|
51
60
|
private deliver;
|
|
61
|
+
private deliverTracked;
|
|
62
|
+
private claimManualRun;
|
|
63
|
+
private finalizeManualRun;
|
|
64
|
+
private claimNextDueJob;
|
|
65
|
+
private finalizeScheduledRun;
|
|
52
66
|
private enqueue;
|
|
53
67
|
private requestDispatch;
|
|
54
68
|
private arm;
|
package/src/manager.js
CHANGED
|
@@ -3,6 +3,7 @@ import { assertMinimumCronInterval, CronExpressionError, MIN_CRON_INTERVAL_SECON
|
|
|
3
3
|
import { CronAutomationInputError } from './protocol.js';
|
|
4
4
|
import { CronAutomationRepository } from './repository.js';
|
|
5
5
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
6
|
+
const DELIVERY_RETRY_DELAY_MS = 30_000;
|
|
6
7
|
function automationId(agentId, requestId) {
|
|
7
8
|
return `cron-${createHash('sha256').update(`${agentId}\0${requestId}`).digest('hex').slice(0, 24)}`;
|
|
8
9
|
}
|
|
@@ -96,15 +97,25 @@ export class CronAutomationManager {
|
|
|
96
97
|
options;
|
|
97
98
|
jobs = new Map();
|
|
98
99
|
blockedAgents = new Set();
|
|
100
|
+
blockedUntil = new Map();
|
|
101
|
+
activeJobs = new Set();
|
|
102
|
+
inflight = new Set();
|
|
99
103
|
now;
|
|
100
104
|
started;
|
|
101
105
|
tail = Promise.resolve();
|
|
106
|
+
dispatchLoop = Promise.resolve();
|
|
102
107
|
timer;
|
|
103
108
|
stopping = false;
|
|
109
|
+
schedulingPaused;
|
|
104
110
|
constructor(repository, options) {
|
|
105
111
|
this.repository = repository;
|
|
106
112
|
this.options = options;
|
|
107
113
|
this.now = options.now ?? Date.now;
|
|
114
|
+
this.schedulingPaused = options.startPaused ?? false;
|
|
115
|
+
}
|
|
116
|
+
resumeScheduling() {
|
|
117
|
+
this.schedulingPaused = false;
|
|
118
|
+
this.arm();
|
|
108
119
|
}
|
|
109
120
|
start() {
|
|
110
121
|
return (this.started ??= this.initialize());
|
|
@@ -136,6 +147,8 @@ export class CronAutomationManager {
|
|
|
136
147
|
clearTimeout(this.timer);
|
|
137
148
|
this.timer = undefined;
|
|
138
149
|
try {
|
|
150
|
+
await this.dispatchLoop.catch(() => undefined);
|
|
151
|
+
await Promise.all([...this.inflight]);
|
|
139
152
|
await this.tail;
|
|
140
153
|
}
|
|
141
154
|
finally {
|
|
@@ -314,33 +327,12 @@ export class CronAutomationManager {
|
|
|
314
327
|
}
|
|
315
328
|
async runOnceStatus(jobId) {
|
|
316
329
|
await this.start();
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
// Imported OpenClaw jobs refer to an OpenClaw agent id, not a DSH session.
|
|
324
|
-
// They must wait until mbh-chat supplies a verified DSH session binding.
|
|
325
|
-
if (!hasDshSession(job))
|
|
326
|
-
return 'delivery_unavailable';
|
|
327
|
-
const occurrenceAtMs = this.now();
|
|
328
|
-
job.state.runningAtMs = occurrenceAtMs;
|
|
329
|
-
job.updatedAtMs = occurrenceAtMs;
|
|
330
|
-
await this.repository.upsert([job]);
|
|
331
|
-
const delivered = await this.deliver(job, occurrenceAtMs);
|
|
332
|
-
delete job.state.runningAtMs;
|
|
333
|
-
job.state.lastRunAtMs = occurrenceAtMs;
|
|
334
|
-
job.state.lastRunStatus = delivered ? 'ok' : 'skipped';
|
|
335
|
-
job.updatedAtMs = this.now();
|
|
336
|
-
await this.repository.upsert([job]);
|
|
337
|
-
if (delivered) {
|
|
338
|
-
this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
339
|
-
return 'dispatched';
|
|
340
|
-
}
|
|
341
|
-
this.options.onChanged?.(cloneJob(job));
|
|
342
|
-
return 'delivery_unavailable';
|
|
343
|
-
});
|
|
330
|
+
const claimed = await this.enqueue(() => this.claimManualRun(jobId));
|
|
331
|
+
if (claimed.kind === 'status')
|
|
332
|
+
return claimed.status;
|
|
333
|
+
const delivered = await this.deliverTracked(claimed.job, claimed.occurrenceAtMs);
|
|
334
|
+
await this.enqueue(() => this.finalizeManualRun(claimed.job.id, claimed.occurrenceAtMs, delivered));
|
|
335
|
+
return delivered ? 'dispatched' : 'delivery_unavailable';
|
|
344
336
|
}
|
|
345
337
|
async runOnce(jobId) {
|
|
346
338
|
return (await this.runOnceStatus(jobId)) === 'dispatched';
|
|
@@ -354,10 +346,25 @@ export class CronAutomationManager {
|
|
|
354
346
|
.map(cloneJob);
|
|
355
347
|
}
|
|
356
348
|
notifyAgentAvailable(agentId) {
|
|
357
|
-
|
|
358
|
-
|
|
349
|
+
this.blockedAgents.delete(agentId);
|
|
350
|
+
this.blockedUntil.delete(agentId);
|
|
359
351
|
this.requestDispatch();
|
|
360
352
|
}
|
|
353
|
+
/** Reflect deletions only for ids recorded in this source's import ledger. */
|
|
354
|
+
async reconcileImportedDeletions(source, presentIds) {
|
|
355
|
+
await this.start();
|
|
356
|
+
return this.enqueue(async () => {
|
|
357
|
+
const removed = await this.repository.removeMissingImportedJobs(source, presentIds);
|
|
358
|
+
for (const id of removed) {
|
|
359
|
+
const job = this.jobs.get(id);
|
|
360
|
+
this.jobs.delete(id);
|
|
361
|
+
if (job)
|
|
362
|
+
this.options.onChanged?.(cloneJob(job));
|
|
363
|
+
}
|
|
364
|
+
this.arm();
|
|
365
|
+
return removed;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
361
368
|
/** Import each source job once. Existing ids win so DSH bindings stay intact. */
|
|
362
369
|
async importFrom(source, incoming) {
|
|
363
370
|
await this.start();
|
|
@@ -415,16 +422,129 @@ export class CronAutomationManager {
|
|
|
415
422
|
return false;
|
|
416
423
|
}
|
|
417
424
|
}
|
|
425
|
+
async deliverTracked(job, occurrenceAtMs) {
|
|
426
|
+
const work = this.deliver(job, occurrenceAtMs);
|
|
427
|
+
this.inflight.add(work);
|
|
428
|
+
try {
|
|
429
|
+
return await work;
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
this.inflight.delete(work);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async claimManualRun(jobId) {
|
|
436
|
+
if (this.stopping)
|
|
437
|
+
return { kind: 'status', status: 'delivery_unavailable' };
|
|
438
|
+
if (this.activeJobs.has(jobId))
|
|
439
|
+
return { kind: 'status', status: 'already_running' };
|
|
440
|
+
const job = this.jobs.get(jobId);
|
|
441
|
+
if (!job)
|
|
442
|
+
return { kind: 'status', status: 'not_found' };
|
|
443
|
+
if (job.state.runningAtMs !== undefined)
|
|
444
|
+
return { kind: 'status', status: 'already_running' };
|
|
445
|
+
// Imported OpenClaw jobs refer to an OpenClaw agent id, not a DSH session.
|
|
446
|
+
// They must wait until mbh-chat supplies a verified DSH session binding.
|
|
447
|
+
if (!hasDshSession(job))
|
|
448
|
+
return { kind: 'status', status: 'delivery_unavailable' };
|
|
449
|
+
const occurrenceAtMs = this.now();
|
|
450
|
+
this.activeJobs.add(jobId);
|
|
451
|
+
job.state.runningAtMs = occurrenceAtMs;
|
|
452
|
+
job.updatedAtMs = occurrenceAtMs;
|
|
453
|
+
try {
|
|
454
|
+
await this.repository.upsert([job]);
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
this.activeJobs.delete(jobId);
|
|
458
|
+
delete job.state.runningAtMs;
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
return { kind: 'ready', job: cloneJob(job), occurrenceAtMs };
|
|
462
|
+
}
|
|
463
|
+
async finalizeManualRun(jobId, occurrenceAtMs, delivered) {
|
|
464
|
+
this.activeJobs.delete(jobId);
|
|
465
|
+
const job = this.jobs.get(jobId);
|
|
466
|
+
if (!job)
|
|
467
|
+
return;
|
|
468
|
+
delete job.state.runningAtMs;
|
|
469
|
+
job.state.lastRunAtMs = occurrenceAtMs;
|
|
470
|
+
job.state.lastRunStatus = delivered ? 'ok' : 'skipped';
|
|
471
|
+
job.updatedAtMs = this.now();
|
|
472
|
+
await this.repository.upsert([job]);
|
|
473
|
+
if (delivered)
|
|
474
|
+
this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
475
|
+
else
|
|
476
|
+
this.options.onChanged?.(cloneJob(job));
|
|
477
|
+
}
|
|
478
|
+
async claimNextDueJob() {
|
|
479
|
+
if (this.stopping || this.schedulingPaused)
|
|
480
|
+
return undefined;
|
|
481
|
+
const now = this.now();
|
|
482
|
+
const job = [...this.jobs.values()]
|
|
483
|
+
.filter((candidate) => candidate.enabled &&
|
|
484
|
+
hasDshSession(candidate) &&
|
|
485
|
+
candidate.state.runningAtMs === undefined &&
|
|
486
|
+
!this.activeJobs.has(candidate.id) &&
|
|
487
|
+
candidate.state.nextRunAtMs !== undefined &&
|
|
488
|
+
candidate.state.nextRunAtMs <= now &&
|
|
489
|
+
(!this.blockedAgents.has(dshAgentIdFor(candidate)) || (this.blockedUntil.get(dshAgentIdFor(candidate)) ?? 0) <= now))
|
|
490
|
+
.sort((left, right) => (left.state.nextRunAtMs ?? 0) - (right.state.nextRunAtMs ?? 0) || left.createdAtMs - right.createdAtMs)[0];
|
|
491
|
+
if (!job)
|
|
492
|
+
return undefined;
|
|
493
|
+
const occurrenceAtMs = job.state.nextRunAtMs ?? now;
|
|
494
|
+
this.activeJobs.add(job.id);
|
|
495
|
+
job.state.runningAtMs = now;
|
|
496
|
+
job.updatedAtMs = now;
|
|
497
|
+
try {
|
|
498
|
+
await this.repository.upsert([job]);
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
this.activeJobs.delete(job.id);
|
|
502
|
+
delete job.state.runningAtMs;
|
|
503
|
+
throw error;
|
|
504
|
+
}
|
|
505
|
+
return { job: cloneJob(job), occurrenceAtMs };
|
|
506
|
+
}
|
|
507
|
+
async finalizeScheduledRun(jobId, occurrenceAtMs, delivered) {
|
|
508
|
+
this.activeJobs.delete(jobId);
|
|
509
|
+
const job = this.jobs.get(jobId);
|
|
510
|
+
if (!job)
|
|
511
|
+
return;
|
|
512
|
+
const now = this.now();
|
|
513
|
+
delete job.state.runningAtMs;
|
|
514
|
+
job.state.lastRunAtMs = now;
|
|
515
|
+
job.state.lastRunStatus = delivered ? 'ok' : 'skipped';
|
|
516
|
+
job.updatedAtMs = now;
|
|
517
|
+
if (!delivered) {
|
|
518
|
+
this.blockedAgents.add(dshAgentIdFor(job));
|
|
519
|
+
this.blockedUntil.set(dshAgentIdFor(job), now + DELIVERY_RETRY_DELAY_MS);
|
|
520
|
+
await this.repository.upsert([job]);
|
|
521
|
+
this.options.onChanged?.(cloneJob(job));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
this.blockedAgents.delete(dshAgentIdFor(job));
|
|
525
|
+
this.blockedUntil.delete(dshAgentIdFor(job));
|
|
526
|
+
if (job.deleteAfterRun === true || job.schedule.kind === 'at') {
|
|
527
|
+
this.jobs.delete(jobId);
|
|
528
|
+
await this.repository.delete(jobId);
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
if (job.enabled)
|
|
532
|
+
setNextOccurrence(job, nextOccurrence(now, job.schedule));
|
|
533
|
+
await this.repository.upsert([job]);
|
|
534
|
+
}
|
|
535
|
+
this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
536
|
+
}
|
|
418
537
|
enqueue(operation) {
|
|
419
538
|
const run = this.tail.then(operation);
|
|
420
539
|
this.tail = run.then(() => undefined, () => undefined);
|
|
421
540
|
return run;
|
|
422
541
|
}
|
|
423
542
|
requestDispatch() {
|
|
424
|
-
|
|
543
|
+
this.dispatchLoop = this.dispatchLoop.then(() => this.dispatchDue(), () => this.dispatchDue());
|
|
544
|
+
void this.dispatchLoop.catch((error) => this.options.onError?.(error));
|
|
425
545
|
}
|
|
426
546
|
arm() {
|
|
427
|
-
if (this.stopping)
|
|
547
|
+
if (this.stopping || this.schedulingPaused)
|
|
428
548
|
return;
|
|
429
549
|
if (this.timer)
|
|
430
550
|
clearTimeout(this.timer);
|
|
@@ -434,8 +554,11 @@ export class CronAutomationManager {
|
|
|
434
554
|
.filter((job) => job.enabled &&
|
|
435
555
|
hasDshSession(job) &&
|
|
436
556
|
job.state.runningAtMs === undefined &&
|
|
437
|
-
!this.blockedAgents.has(dshAgentIdFor(job)))
|
|
438
|
-
.map((job) =>
|
|
557
|
+
(!this.blockedAgents.has(dshAgentIdFor(job)) || (this.blockedUntil.get(dshAgentIdFor(job)) ?? 0) <= now))
|
|
558
|
+
.map((job) => {
|
|
559
|
+
const retryAt = this.blockedUntil.get(dshAgentIdFor(job));
|
|
560
|
+
return retryAt !== undefined && retryAt > now ? retryAt : job.state.nextRunAtMs;
|
|
561
|
+
})
|
|
439
562
|
.filter((value) => value !== undefined)
|
|
440
563
|
.reduce((earliest, candidate) => (earliest === undefined || candidate < earliest ? candidate : earliest), undefined);
|
|
441
564
|
if (target === undefined)
|
|
@@ -448,38 +571,17 @@ export class CronAutomationManager {
|
|
|
448
571
|
this.timer.unref();
|
|
449
572
|
}
|
|
450
573
|
async dispatchDue() {
|
|
451
|
-
if (this.stopping)
|
|
574
|
+
if (this.stopping || this.schedulingPaused)
|
|
452
575
|
return;
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
job.state.runningAtMs = now;
|
|
460
|
-
job.updatedAtMs = now;
|
|
461
|
-
await this.repository.upsert([job]);
|
|
462
|
-
const delivered = await this.deliver(job, occurrenceAtMs);
|
|
463
|
-
delete job.state.runningAtMs;
|
|
464
|
-
job.state.lastRunAtMs = now;
|
|
465
|
-
job.state.lastRunStatus = delivered ? 'ok' : 'skipped';
|
|
466
|
-
job.updatedAtMs = now;
|
|
467
|
-
if (!delivered) {
|
|
468
|
-
this.blockedAgents.add(dshAgentIdFor(job));
|
|
469
|
-
await this.repository.upsert([job]);
|
|
470
|
-
this.options.onChanged?.(cloneJob(job));
|
|
471
|
-
continue;
|
|
472
|
-
}
|
|
473
|
-
if (job.deleteAfterRun === true || job.schedule.kind === 'at') {
|
|
474
|
-
this.jobs.delete(job.id);
|
|
475
|
-
await this.repository.delete(job.id);
|
|
476
|
-
}
|
|
477
|
-
else {
|
|
478
|
-
setNextOccurrence(job, nextOccurrence(now, job.schedule));
|
|
479
|
-
await this.repository.upsert([job]);
|
|
480
|
-
}
|
|
481
|
-
this.options.onDispatched?.(cloneJob(job), new Date(occurrenceAtMs).toISOString());
|
|
576
|
+
for (;;) {
|
|
577
|
+
const claimed = await this.enqueue(() => this.claimNextDueJob());
|
|
578
|
+
if (!claimed)
|
|
579
|
+
break;
|
|
580
|
+
const delivered = await this.deliverTracked(claimed.job, claimed.occurrenceAtMs);
|
|
581
|
+
await this.enqueue(() => this.finalizeScheduledRun(claimed.job.id, claimed.occurrenceAtMs, delivered));
|
|
482
582
|
}
|
|
483
|
-
this.
|
|
583
|
+
await this.enqueue(async () => {
|
|
584
|
+
this.arm();
|
|
585
|
+
});
|
|
484
586
|
}
|
|
485
587
|
}
|
package/src/openclaw-sqlite.d.ts
CHANGED
package/src/openclaw-sqlite.js
CHANGED
|
@@ -110,12 +110,12 @@ export function loadOpenClawSqliteJobs(sqlitePath) {
|
|
|
110
110
|
}
|
|
111
111
|
export function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
|
|
112
112
|
if (!existsSync(sqlitePath))
|
|
113
|
-
return { jobs: [], skipped: [] };
|
|
113
|
+
return { jobs: [], skipped: [], complete: false };
|
|
114
114
|
const database = new DatabaseSync(sqlitePath, { readOnly: true, timeout: 5_000 });
|
|
115
115
|
try {
|
|
116
116
|
const table = database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'cron_jobs'").get();
|
|
117
117
|
if (!table)
|
|
118
|
-
return { jobs: [], skipped: [] };
|
|
118
|
+
return { jobs: [], skipped: [], complete: false };
|
|
119
119
|
// SELECT * deliberately tolerates OpenClaw schema additions and older schemas
|
|
120
120
|
// that omit newer nullable projection columns. job_json remains authoritative.
|
|
121
121
|
const rows = database
|
|
@@ -139,7 +139,7 @@ export function loadOpenClawSqliteJobsWithDiagnostics(sqlitePath) {
|
|
|
139
139
|
});
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
return { jobs: [...jobs.values()], skipped };
|
|
142
|
+
return { jobs: [...jobs.values()], skipped, complete: skipped.length === 0 };
|
|
143
143
|
}
|
|
144
144
|
finally {
|
|
145
145
|
database.close();
|
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,12 +77,15 @@ 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>;
|
|
83
84
|
previewImport(source: string, incoming: readonly StoredCronAutomation[]): Promise<CronImportResult>;
|
|
84
85
|
import(source: string, incoming: readonly StoredCronAutomation[]): Promise<CronImportResult>;
|
|
85
86
|
listImportedJobs(): Promise<CronImportedJob[]>;
|
|
87
|
+
/** Only a verified complete source snapshot may authorize these deletions. */
|
|
88
|
+
removeMissingImportedJobs(source: string, presentIds: ReadonlySet<string>): Promise<string[]>;
|
|
86
89
|
migrateLegacy(migrationId: string, incoming: readonly StoredCronAutomation[], importedJobs: readonly CronImportedJob[]): Promise<CronLegacyMigrationResult>;
|
|
87
90
|
close(): void;
|
|
88
91
|
private planImport;
|
package/src/repository.js
CHANGED
|
@@ -285,6 +285,13 @@ 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
|
|
@@ -344,6 +351,27 @@ export class CronAutomationRepository {
|
|
|
344
351
|
return [];
|
|
345
352
|
return this.database.prepare('SELECT source, job_id FROM cron_imported_jobs').all().map((row) => ({ source: row.source, jobId: row.job_id }));
|
|
346
353
|
}
|
|
354
|
+
/** Only a verified complete source snapshot may authorize these deletions. */
|
|
355
|
+
async removeMissingImportedJobs(source, presentIds) {
|
|
356
|
+
this.assertWritable();
|
|
357
|
+
if (!source.trim())
|
|
358
|
+
throw new Error('cron import source must be non-empty');
|
|
359
|
+
return this.writeTransaction(() => {
|
|
360
|
+
const rows = this.database
|
|
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 = ?
|
|
365
|
+
`)
|
|
366
|
+
.all(STORE_KEY, source);
|
|
367
|
+
const removed = rows.map((row) => row.job_id).filter((id) => !presentIds.has(id));
|
|
368
|
+
const statement = this.database.prepare('DELETE FROM cron_jobs WHERE store_key = ? AND job_id = ?');
|
|
369
|
+
for (const id of removed)
|
|
370
|
+
statement.run(STORE_KEY, id);
|
|
371
|
+
// Keep the import ledger: an old snapshot must not resurrect a deletion.
|
|
372
|
+
return removed;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
347
375
|
async migrateLegacy(migrationId, incoming, importedJobs) {
|
|
348
376
|
this.assertWritable();
|
|
349
377
|
const decoded = uniqueJobs(incoming);
|
|
@@ -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');
|