@goodandready/dsh-cron 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/lib/adaptive.js +151 -0
- package/lib/api.js +8 -0
- package/lib/chain-checkpoints.js +109 -11
- package/lib/client.js +4505 -4301
- package/lib/dag.js +330 -0
- package/lib/log-streamer.js +153 -0
- package/lib/prompt-interpolation.js +29 -3
- package/lib/routes.js +22 -0
- package/lib/runner-external.js +10 -0
- package/lib/runner.js +18 -1
- package/lib/scheduler-execution.js +108 -15
- package/lib/scheduler.js +38 -0
- package/lib/task-create.js +13 -1
- package/lib/task-patch.js +17 -0
- package/lib/task-transfer.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -565,6 +565,7 @@ All endpoints are served by the DSH web server under `/dsh-cron/`. Endpoints are
|
|
|
565
565
|
| `POST` | `/dsh-cron/tasks` | Create or update a task (`id` present → update). Requires `title`, `schedule`, `prompt` |
|
|
566
566
|
| `GET` | `/dsh-cron/tasks/:id/history` | Run history, `?limit=20` |
|
|
567
567
|
| `POST` | `/dsh-cron/tasks/:id/run` | Trigger an immediate manual run |
|
|
568
|
+
| `GET` | `/dsh-cron/tasks/:id/stream` | Real-time Server-Sent Events (SSE) execution log streaming with ring buffer (#336) |
|
|
568
569
|
| `POST` | `/dsh-cron/tasks/:id/pause` | Pause the schedule |
|
|
569
570
|
| `POST` | `/dsh-cron/tasks/:id/resume` | Resume the schedule |
|
|
570
571
|
| `POST` | `/dsh-cron/tasks/:id/toggle` | Toggle active/paused |
|
|
@@ -590,6 +591,28 @@ All endpoints are served by the DSH web server under `/dsh-cron/`. Endpoints are
|
|
|
590
591
|
|
|
591
592
|
---
|
|
592
593
|
|
|
594
|
+
### ⚡ Adaptive Polling & Exponential Backoff (#329)
|
|
595
|
+
|
|
596
|
+
Save LLM tokens and API quotas on watcher tasks by dynamically slowing down execution when no changes occur:
|
|
597
|
+
|
|
598
|
+
```json
|
|
599
|
+
"adaptive": {
|
|
600
|
+
"enabled": true,
|
|
601
|
+
"minInterval": "5m",
|
|
602
|
+
"maxInterval": "2h",
|
|
603
|
+
"backoffFactor": 1.5,
|
|
604
|
+
"idleRunsThreshold": 3
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
- Multiplies interval by `backoffFactor` up to `maxInterval` after `idleRunsThreshold` silent or unchanged runs.
|
|
608
|
+
- Instantly resets to `minInterval` as soon as real changes or non-silent output are detected.
|
|
609
|
+
|
|
610
|
+
### 📺 Real-Time Streaming Logs via SSE (#336)
|
|
611
|
+
|
|
612
|
+
Connect to `GET /dsh-cron/tasks/:id/stream` using Server-Sent Events (SSE) to receive live stdout/stderr chunks and execution completion events with an automatic 500-line ring buffer.
|
|
613
|
+
|
|
614
|
+
---
|
|
615
|
+
|
|
593
616
|
## 🧪 Testing & Preflight Verification
|
|
594
617
|
|
|
595
618
|
Run the automated test suite covering schedule parsing, the scheduler engine, atomic storage, HTTP helpers, notifications and tool contracts:
|
package/lib/adaptive.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive Polling & Exponential Backoff (#329)
|
|
3
|
+
*
|
|
4
|
+
* Automatically scales execution intervals up when no changes / idle runs are detected
|
|
5
|
+
* (e.g. silent skips, unchanged outputs) and instantly resets back to minInterval
|
|
6
|
+
* when new data is returned.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { logger } from './logger.js';
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_ADAPTIVE_CONFIG = {
|
|
12
|
+
enabled: false,
|
|
13
|
+
minInterval: '5m',
|
|
14
|
+
maxInterval: '2h',
|
|
15
|
+
backoffFactor: 1.5,
|
|
16
|
+
idleRunsThreshold: 3,
|
|
17
|
+
detectUnchangedOutput: true,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse human interval string (e.g. "30s", "5m", "2h", "1d") or number into milliseconds.
|
|
22
|
+
*/
|
|
23
|
+
export function parseIntervalMs(val, defaultMs = 300000) {
|
|
24
|
+
if (typeof val === 'number' && !isNaN(val) && val > 0) {
|
|
25
|
+
return Math.round(val);
|
|
26
|
+
}
|
|
27
|
+
if (!val || typeof val !== 'string') return defaultMs;
|
|
28
|
+
|
|
29
|
+
const match = val.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i);
|
|
30
|
+
if (!match) return defaultMs;
|
|
31
|
+
|
|
32
|
+
const num = parseFloat(match[1]);
|
|
33
|
+
const unit = (match[2] || 'm').toLowerCase();
|
|
34
|
+
|
|
35
|
+
switch (unit) {
|
|
36
|
+
case 'ms': return Math.round(num);
|
|
37
|
+
case 's': return Math.round(num * 1000);
|
|
38
|
+
case 'm': return Math.round(num * 60 * 1000);
|
|
39
|
+
case 'h': return Math.round(num * 3600 * 1000);
|
|
40
|
+
case 'd': return Math.round(num * 86400 * 1000);
|
|
41
|
+
default: return defaultMs;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Format milliseconds into human readable interval (e.g. "5m", "1h 30m", "45s").
|
|
47
|
+
*/
|
|
48
|
+
export function formatInterval(ms) {
|
|
49
|
+
if (!ms || typeof ms !== 'number' || ms <= 0) return '0s';
|
|
50
|
+
const sec = Math.round(ms / 1000);
|
|
51
|
+
if (sec < 60) return `${sec}s`;
|
|
52
|
+
const min = Math.floor(sec / 60);
|
|
53
|
+
const remSec = sec % 60;
|
|
54
|
+
if (min < 60) return remSec > 0 ? `${min}m ${remSec}s` : `${min}m`;
|
|
55
|
+
const hrs = Math.floor(min / 60);
|
|
56
|
+
const remMin = min % 60;
|
|
57
|
+
return remMin > 0 ? `${hrs}h ${remMin}m` : `${hrs}h`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Sanitize and normalize adaptive configuration from payload.
|
|
62
|
+
*/
|
|
63
|
+
export function sanitizeAdaptiveConfig(adaptive) {
|
|
64
|
+
if (!adaptive || typeof adaptive !== 'object') {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const enabled = Boolean(adaptive.enabled);
|
|
68
|
+
const minInterval = typeof adaptive.minInterval === 'string' ? adaptive.minInterval.trim() : (adaptive.minInterval ? String(adaptive.minInterval) : '5m');
|
|
69
|
+
const maxInterval = typeof adaptive.maxInterval === 'string' ? adaptive.maxInterval.trim() : (adaptive.maxInterval ? String(adaptive.maxInterval) : '2h');
|
|
70
|
+
const backoffFactor = (typeof adaptive.backoffFactor === 'number' && adaptive.backoffFactor >= 1) ? adaptive.backoffFactor : 1.5;
|
|
71
|
+
const idleRunsThreshold = (typeof adaptive.idleRunsThreshold === 'number' && adaptive.idleRunsThreshold >= 1) ? Math.floor(adaptive.idleRunsThreshold) : 3;
|
|
72
|
+
const detectUnchangedOutput = adaptive.detectUnchangedOutput !== false;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
enabled,
|
|
76
|
+
minInterval,
|
|
77
|
+
maxInterval,
|
|
78
|
+
backoffFactor,
|
|
79
|
+
idleRunsThreshold,
|
|
80
|
+
detectUnchangedOutput,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Evaluate if a completed run constitutes an "idle / no-change" execution.
|
|
86
|
+
*/
|
|
87
|
+
export function evaluateAdaptiveRun(task, outcome, runInfo, lastRun) {
|
|
88
|
+
if (outcome?.status === 'silentSkip' || runInfo?.silentSkip === true) {
|
|
89
|
+
return { isIdle: true, reason: 'silent_rule' };
|
|
90
|
+
}
|
|
91
|
+
if (runInfo?.validation && runInfo.validation.noChange === true) {
|
|
92
|
+
return { isIdle: true, reason: 'validator_no_change' };
|
|
93
|
+
}
|
|
94
|
+
if (task.adaptive?.detectUnchangedOutput !== false && lastRun && outcome?.status === 'success') {
|
|
95
|
+
const currentOut = String(outcome.output || '').trim();
|
|
96
|
+
const lastOut = String(lastRun.output || '').trim();
|
|
97
|
+
if (currentOut && lastOut && currentOut === lastOut) {
|
|
98
|
+
return { isIdle: true, reason: 'unchanged_output' };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { isIdle: false, reason: 'active_output' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Calculate the next adaptive state and schedule interval.
|
|
106
|
+
*/
|
|
107
|
+
export function calculateNextAdaptiveState(task, isIdle, now = Date.now()) {
|
|
108
|
+
const config = sanitizeAdaptiveConfig(task.adaptive) || DEFAULT_ADAPTIVE_CONFIG;
|
|
109
|
+
const minMs = parseIntervalMs(config.minInterval, 300000);
|
|
110
|
+
const maxMs = parseIntervalMs(config.maxInterval, 7200000);
|
|
111
|
+
const factor = config.backoffFactor || 1.5;
|
|
112
|
+
const threshold = config.idleRunsThreshold || 3;
|
|
113
|
+
|
|
114
|
+
const prevState = task.adaptiveState || {
|
|
115
|
+
idleCount: 0,
|
|
116
|
+
currentIntervalMs: minMs,
|
|
117
|
+
effectiveIntervalStr: formatInterval(minMs),
|
|
118
|
+
active: false,
|
|
119
|
+
lastAdjustedAt: now,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (!isIdle) {
|
|
123
|
+
// Reset back to base minInterval immediately upon real changes
|
|
124
|
+
return {
|
|
125
|
+
idleCount: 0,
|
|
126
|
+
currentIntervalMs: minMs,
|
|
127
|
+
effectiveIntervalStr: formatInterval(minMs),
|
|
128
|
+
active: false,
|
|
129
|
+
lastAdjustedAt: now,
|
|
130
|
+
backedOff: false,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const idleCount = (prevState.idleCount || 0) + 1;
|
|
135
|
+
let currentIntervalMs = prevState.currentIntervalMs || minMs;
|
|
136
|
+
let backedOff = false;
|
|
137
|
+
|
|
138
|
+
if (idleCount >= threshold) {
|
|
139
|
+
backedOff = true;
|
|
140
|
+
currentIntervalMs = Math.min(Math.round(currentIntervalMs * factor), maxMs);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
idleCount,
|
|
145
|
+
currentIntervalMs,
|
|
146
|
+
effectiveIntervalStr: formatInterval(currentIntervalMs),
|
|
147
|
+
active: backedOff,
|
|
148
|
+
lastAdjustedAt: now,
|
|
149
|
+
backedOff,
|
|
150
|
+
};
|
|
151
|
+
}
|
package/lib/api.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { sanitizeAdaptiveConfig } from './adaptive.js';
|
|
2
|
+
import { logStreamer } from './log-streamer.js';
|
|
1
3
|
import { approvalManager } from './approval.js';
|
|
2
4
|
import { sanitizeValidators } from './validators.js';
|
|
3
5
|
/**
|
|
@@ -289,6 +291,10 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
|
|
|
289
291
|
}
|
|
290
292
|
|
|
291
293
|
if (req.method === 'GET') {
|
|
294
|
+
if (action === 'stream') {
|
|
295
|
+
logStreamer.handleSseRequest(id, req, res);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
292
298
|
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
293
299
|
if (action === 'archive') {
|
|
294
300
|
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
@@ -596,6 +602,8 @@ function mergeExecutionFields(current, body, parsed) {
|
|
|
596
602
|
concurrencyGroup: body.concurrencyGroup !== undefined ? String(body.concurrencyGroup).trim() : (current ? (current.concurrencyGroup || 'default') : 'default'),
|
|
597
603
|
selfHealingCommand: body.selfHealingCommand !== undefined ? String(body.selfHealingCommand).trim() : (current ? (current.selfHealingCommand || '') : ''),
|
|
598
604
|
autoDiagnose: body.autoDiagnose !== undefined ? Boolean(body.autoDiagnose) : (current ? Boolean(current.autoDiagnose) : false),
|
|
605
|
+
adaptive: body.adaptive !== undefined ? sanitizeAdaptiveConfig(body.adaptive) : (current ? current.adaptive : undefined),
|
|
606
|
+
adaptiveState: current ? current.adaptiveState : undefined,
|
|
599
607
|
};
|
|
600
608
|
}
|
|
601
609
|
|
package/lib/chain-checkpoints.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Chain Checkpoint
|
|
2
|
+
* Chain Checkpoint, Step Replay, and DAG Barrier Manager (#319, #328)
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { sanitizeDependsOn, sanitizeJoinPolicy } from './dag.js';
|
|
6
|
+
|
|
5
7
|
export class ChainCheckpointManager {
|
|
6
8
|
constructor(store) {
|
|
7
9
|
this.store = store;
|
|
8
10
|
this.activeRetries = new Set();
|
|
11
|
+
this.triggeredBarriers = new Set();
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
/**
|
|
@@ -48,6 +51,86 @@ export class ChainCheckpointManager {
|
|
|
48
51
|
return this.store.chainCheckpoints[chainRunId] || [];
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Checks whether all prerequisites for a task are satisfied (DAG Join Barrier, #328).
|
|
56
|
+
*
|
|
57
|
+
* @param {string} chainRunId
|
|
58
|
+
* @param {Object} task
|
|
59
|
+
* @returns {{ ready: boolean, pending?: boolean, failed?: boolean, outputs: Record<string, string>, latestOutput: string, waitingFor?: string[], failedDep?: string }}
|
|
60
|
+
*/
|
|
61
|
+
checkJoinBarrier(chainRunId, task) {
|
|
62
|
+
if (!task) return { ready: false, outputs: {}, latestOutput: '' };
|
|
63
|
+
const deps = sanitizeDependsOn(task.dependsOn);
|
|
64
|
+
if (!deps.length) {
|
|
65
|
+
return { ready: true, outputs: {}, latestOutput: '' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const snapshots = this.getChainSnapshots(chainRunId);
|
|
69
|
+
const snapMap = new Map();
|
|
70
|
+
for (const s of snapshots) {
|
|
71
|
+
if (s?.stepTaskId) snapMap.set(s.stepTaskId, s);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const policy = sanitizeJoinPolicy(task.joinPolicy);
|
|
75
|
+
const outputs = {};
|
|
76
|
+
let latestOutput = '';
|
|
77
|
+
const waitingFor = [];
|
|
78
|
+
let hasSuccess = false;
|
|
79
|
+
let failedDep = null;
|
|
80
|
+
|
|
81
|
+
for (const depId of deps) {
|
|
82
|
+
const snap = snapMap.get(depId);
|
|
83
|
+
if (!snap) {
|
|
84
|
+
waitingFor.push(depId);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const isOk = snap.status === 'completed' || snap.status === 'success';
|
|
88
|
+
if (isOk) {
|
|
89
|
+
hasSuccess = true;
|
|
90
|
+
outputs[depId] = String(snap.output || '');
|
|
91
|
+
latestOutput = String(snap.output || '');
|
|
92
|
+
} else {
|
|
93
|
+
failedDep = depId;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (policy === 'any_success') {
|
|
98
|
+
if (hasSuccess) {
|
|
99
|
+
return { ready: true, outputs, latestOutput };
|
|
100
|
+
}
|
|
101
|
+
if (waitingFor.length === 0) {
|
|
102
|
+
return { ready: false, failed: true, failedDep: failedDep || deps[0], outputs, latestOutput };
|
|
103
|
+
}
|
|
104
|
+
return { ready: false, pending: true, waitingFor, outputs, latestOutput };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Default: 'all_success'
|
|
108
|
+
if (waitingFor.length > 0) {
|
|
109
|
+
return { ready: false, pending: true, waitingFor, outputs, latestOutput };
|
|
110
|
+
}
|
|
111
|
+
if (failedDep) {
|
|
112
|
+
return { ready: false, failed: true, failedDep, outputs, latestOutput };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { ready: true, outputs, latestOutput };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Barrier idempotency guard: returns true if barrier has not yet fired for this task in this run.
|
|
120
|
+
*/
|
|
121
|
+
canTriggerBarrier(chainRunId, taskId) {
|
|
122
|
+
if (!chainRunId || !taskId) return false;
|
|
123
|
+
return !this.triggeredBarriers.has(`${chainRunId}:${taskId}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Marks a barrier as triggered for this chain run to prevent duplicate executions.
|
|
128
|
+
*/
|
|
129
|
+
markBarrierTriggered(chainRunId, taskId) {
|
|
130
|
+
if (!chainRunId || !taskId) return;
|
|
131
|
+
this.triggeredBarriers.add(`${chainRunId}:${taskId}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
51
134
|
/**
|
|
52
135
|
* Checks whether a step can be retried in a chain run
|
|
53
136
|
*/
|
|
@@ -78,7 +161,7 @@ export class ChainCheckpointManager {
|
|
|
78
161
|
}
|
|
79
162
|
|
|
80
163
|
/**
|
|
81
|
-
* Retries chain execution from the specified failed step (#319)
|
|
164
|
+
* Retries chain execution from the specified failed step (#319, #328)
|
|
82
165
|
* Reuses upstream outputs without re-executing successful upstream steps.
|
|
83
166
|
*/
|
|
84
167
|
async retryChainFromStep(scheduler, chainRunId, stepTaskId) {
|
|
@@ -90,25 +173,40 @@ export class ChainCheckpointManager {
|
|
|
90
173
|
|
|
91
174
|
try {
|
|
92
175
|
const snapshots = this.getChainSnapshots(chainRunId);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
176
|
+
|
|
177
|
+
// Collect upstream outputs from checkpoints
|
|
178
|
+
const prevOutputs = {};
|
|
179
|
+
let prevOutput = '';
|
|
180
|
+
let prevTaskId = '';
|
|
181
|
+
|
|
182
|
+
for (const s of snapshots) {
|
|
183
|
+
if (s.stepTaskId !== stepTaskId && (s.status === 'success' || s.status === 'completed')) {
|
|
184
|
+
prevOutputs[s.stepTaskId] = String(s.output || '');
|
|
185
|
+
prevOutput = String(s.output || '');
|
|
186
|
+
prevTaskId = s.stepTaskId;
|
|
100
187
|
}
|
|
101
188
|
}
|
|
102
189
|
|
|
103
|
-
|
|
104
|
-
|
|
190
|
+
// Reset barrier locks for stepTaskId and downstream dependents so they can re-trigger
|
|
191
|
+
this.triggeredBarriers.delete(`${chainRunId}:${stepTaskId}`);
|
|
192
|
+
if (this.store?.tasks) {
|
|
193
|
+
for (const t of Object.values(this.store.tasks)) {
|
|
194
|
+
const deps = sanitizeDependsOn(t?.dependsOn);
|
|
195
|
+
if (deps.includes(stepTaskId)) {
|
|
196
|
+
this.triggeredBarriers.delete(`${chainRunId}:${t.id}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
105
200
|
|
|
106
201
|
// Execute only the failed step, passing reconstructed chain context
|
|
107
202
|
const runFn = (scheduler.runTask || scheduler.runNow).bind(scheduler);
|
|
108
203
|
const res = await runFn(stepTaskId, {
|
|
109
204
|
chainRunId,
|
|
205
|
+
prevOutputs,
|
|
110
206
|
chainPrevOutput: prevOutput,
|
|
207
|
+
prevOutput,
|
|
111
208
|
chainPrevTaskId: prevTaskId,
|
|
209
|
+
prevTaskId,
|
|
112
210
|
isRetry: true,
|
|
113
211
|
});
|
|
114
212
|
|