@goodandready/dsh-cron 0.2.52 → 0.3.0
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/lib/api.js +24 -0
- package/lib/artifacts.js +200 -0
- package/lib/chain-checkpoints.js +125 -0
- package/lib/client.js +66 -75
- package/lib/durable-journal.js +188 -0
- package/lib/routes.js +84 -0
- package/lib/scheduler-execution.js +58 -0
- package/lib/scheduler.js +20 -0
- package/lib/store.js +50 -1
- package/package.json +1 -1
package/lib/api.js
CHANGED
|
@@ -407,6 +407,30 @@ async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
|
|
|
407
407
|
sendJson(res, 200, { ok: true, proposal: result.proposal });
|
|
408
408
|
return true;
|
|
409
409
|
}
|
|
410
|
+
if (action === 'resolve-uncertain') {
|
|
411
|
+
const task = store.get(id);
|
|
412
|
+
if (!task) {
|
|
413
|
+
sendJson(res, 404, NOT_FOUND);
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
const body = await readBody(req).then(b => JSON.parse(b || '{}')).catch(() => ({}));
|
|
417
|
+
const runId = body.runId || url.searchParams.get('runId');
|
|
418
|
+
if (!runId) {
|
|
419
|
+
sendJson(res, 400, { ok: false, error: 'runId is required' });
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
const result = store.durableJournal.resolveUncertain(id, runId, {
|
|
423
|
+
decision: body.decision || 'mark_failed',
|
|
424
|
+
note: body.note || '',
|
|
425
|
+
resolvedBy: body.resolvedBy || 'user',
|
|
426
|
+
});
|
|
427
|
+
if (!result.ok) {
|
|
428
|
+
sendJson(res, 400, result);
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
sendJson(res, 200, result);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
410
434
|
if (action === 'run') {
|
|
411
435
|
if (!store.get(id)) {
|
|
412
436
|
sendJson(res, 404, NOT_FOUND);
|
package/lib/artifacts.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const SAFE_MIME_TYPES = {
|
|
6
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
7
|
+
'.json': 'application/json; charset=utf-8',
|
|
8
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
9
|
+
'.log': 'text/plain; charset=utf-8',
|
|
10
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
11
|
+
'.pdf': 'application/pdf',
|
|
12
|
+
'.png': 'image/png',
|
|
13
|
+
'.jpg': 'image/jpeg',
|
|
14
|
+
'.jpeg': 'image/jpeg',
|
|
15
|
+
'.webp': 'image/webp',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export class ArtifactManager {
|
|
19
|
+
constructor(baseDir) {
|
|
20
|
+
this.baseDir = baseDir || path.join(process.cwd(), 'artifacts');
|
|
21
|
+
this.maxArtifactBytes = 10 * 1024 * 1024; // 10 MiB per artifact
|
|
22
|
+
this.defaultRetentionDays = 7;
|
|
23
|
+
this.maxTotalStorageBytes = 50 * 1024 * 1024; // 50 MiB total quota
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
ensureDir() {
|
|
27
|
+
if (!fs.existsSync(this.baseDir)) {
|
|
28
|
+
fs.mkdirSync(this.baseDir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sanitizes a requested filename to prevent path traversal (#321)
|
|
34
|
+
*/
|
|
35
|
+
sanitizeFilename(name) {
|
|
36
|
+
if (!name || typeof name !== 'string') return 'artifact.txt';
|
|
37
|
+
const clean = path.basename(name).replace(/[\0\r\n\\\/]/g, '_');
|
|
38
|
+
return clean || 'artifact.txt';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getFilePath(artifactId, filename) {
|
|
42
|
+
if (!filename || filename.includes('..') || path.isAbsolute(filename)) {
|
|
43
|
+
throw new Error('Path traversal attempt detected in artifact storage');
|
|
44
|
+
}
|
|
45
|
+
const safeName = this.sanitizeFilename(filename);
|
|
46
|
+
const target = path.resolve(this.baseDir, `${artifactId}_${safeName}`);
|
|
47
|
+
// Security check: ensure path stays strictly within baseDir
|
|
48
|
+
if (!target.startsWith(path.resolve(this.baseDir) + path.sep)) {
|
|
49
|
+
throw new Error('Path traversal attempt detected in artifact storage');
|
|
50
|
+
}
|
|
51
|
+
return target;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolves safe MIME type for download/preview
|
|
56
|
+
*/
|
|
57
|
+
resolveMimeType(filename) {
|
|
58
|
+
const ext = path.extname(filename).toLowerCase();
|
|
59
|
+
// Neutralize dangerous web formats to prevent XSS / script execution
|
|
60
|
+
if (ext === '.html' || ext === '.htm' || ext === '.svg' || ext === '.js') {
|
|
61
|
+
return 'text/plain; charset=utf-8';
|
|
62
|
+
}
|
|
63
|
+
return SAFE_MIME_TYPES[ext] || 'application/octet-stream';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Saves a new artifact file bound to a run (#321)
|
|
68
|
+
*/
|
|
69
|
+
saveArtifact(runId, taskId, filename, contentBuffer) {
|
|
70
|
+
this.ensureDir();
|
|
71
|
+
const buf = Buffer.isBuffer(contentBuffer) ? contentBuffer : Buffer.from(contentBuffer || '', 'utf8');
|
|
72
|
+
|
|
73
|
+
if (buf.length > this.maxArtifactBytes) {
|
|
74
|
+
throw new Error(`Artifact size (${buf.length} bytes) exceeds limit of ${this.maxArtifactBytes} bytes`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const artifactId = 'art_' + randomUUID().replace(/-/g, '').slice(0, 16);
|
|
78
|
+
const safeName = this.sanitizeFilename(filename);
|
|
79
|
+
const filePath = this.getFilePath(artifactId, safeName);
|
|
80
|
+
|
|
81
|
+
fs.writeFileSync(filePath, buf);
|
|
82
|
+
|
|
83
|
+
const sha256 = createHash('sha256').update(buf).digest('hex');
|
|
84
|
+
const mimeType = this.resolveMimeType(safeName);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
id: artifactId,
|
|
88
|
+
name: safeName,
|
|
89
|
+
sizeBytes: buf.length,
|
|
90
|
+
mimeType,
|
|
91
|
+
sha256,
|
|
92
|
+
runId,
|
|
93
|
+
taskId,
|
|
94
|
+
createdAt: Date.now(),
|
|
95
|
+
status: 'available',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reads an artifact file buffer
|
|
101
|
+
*/
|
|
102
|
+
readArtifact(artifactId, filename) {
|
|
103
|
+
const filePath = this.getFilePath(artifactId, filename);
|
|
104
|
+
if (!fs.existsSync(filePath)) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return fs.readFileSync(filePath);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Generates a safe preview of the artifact content (first 64 KiB)
|
|
112
|
+
*/
|
|
113
|
+
generatePreview(artifactId, filename) {
|
|
114
|
+
const buf = this.readArtifact(artifactId, filename);
|
|
115
|
+
if (!buf) return { ok: false, error: 'Artifact file not found on disk' };
|
|
116
|
+
|
|
117
|
+
const ext = path.extname(filename).toLowerCase();
|
|
118
|
+
const isText = ['.txt', '.csv', '.json', '.log', '.md'].includes(ext);
|
|
119
|
+
|
|
120
|
+
if (isText) {
|
|
121
|
+
const slice = buf.subarray(0, 64 * 1024);
|
|
122
|
+
return {
|
|
123
|
+
ok: true,
|
|
124
|
+
kind: 'text',
|
|
125
|
+
mimeType: this.resolveMimeType(filename),
|
|
126
|
+
content: slice.toString('utf8'),
|
|
127
|
+
truncated: buf.length > slice.length,
|
|
128
|
+
sizeBytes: buf.length,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
kind: 'binary',
|
|
135
|
+
mimeType: this.resolveMimeType(filename),
|
|
136
|
+
sizeBytes: buf.length,
|
|
137
|
+
preview: `[Binary file: ${filename} (${buf.length} bytes)]`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Cleans up expired artifacts and enforces size quotas (#321)
|
|
143
|
+
*/
|
|
144
|
+
cleanupArtifacts(store, { retentionDays = this.defaultRetentionDays, maxTotalBytes = this.maxTotalStorageBytes } = {}) {
|
|
145
|
+
if (!fs.existsSync(this.baseDir)) return { removedCount: 0, freedBytes: 0 };
|
|
146
|
+
|
|
147
|
+
const cutoff = Date.now() - retentionDays * 86400000;
|
|
148
|
+
let files = fs.readdirSync(this.baseDir);
|
|
149
|
+
let totalBytes = 0;
|
|
150
|
+
const fileStats = [];
|
|
151
|
+
|
|
152
|
+
for (const f of files) {
|
|
153
|
+
const p = path.join(this.baseDir, f);
|
|
154
|
+
try {
|
|
155
|
+
const stat = fs.statSync(p);
|
|
156
|
+
totalBytes += stat.size;
|
|
157
|
+
fileStats.push({ path: p, filename: f, mtime: stat.mtimeMs, size: stat.size });
|
|
158
|
+
} catch (e) {
|
|
159
|
+
// file unlinked concurrently
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let removedCount = 0;
|
|
164
|
+
let freedBytes = 0;
|
|
165
|
+
|
|
166
|
+
// 1. Remove files older than retentionDays
|
|
167
|
+
for (const item of fileStats) {
|
|
168
|
+
if (item.mtime < cutoff) {
|
|
169
|
+
try {
|
|
170
|
+
fs.unlinkSync(item.path);
|
|
171
|
+
removedCount++;
|
|
172
|
+
freedBytes += item.size;
|
|
173
|
+
totalBytes -= item.size;
|
|
174
|
+
} catch (e) {
|
|
175
|
+
void e;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 2. Enforce quota: remove oldest files if totalBytes > maxTotalBytes
|
|
181
|
+
if (totalBytes > maxTotalBytes) {
|
|
182
|
+
fileStats.sort((a, b) => a.mtime - b.mtime);
|
|
183
|
+
for (const item of fileStats) {
|
|
184
|
+
if (totalBytes <= maxTotalBytes) break;
|
|
185
|
+
if (fs.existsSync(item.path)) {
|
|
186
|
+
try {
|
|
187
|
+
fs.unlinkSync(item.path);
|
|
188
|
+
removedCount++;
|
|
189
|
+
freedBytes += item.size;
|
|
190
|
+
totalBytes -= item.size;
|
|
191
|
+
} catch (e) {
|
|
192
|
+
void e;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { removedCount, freedBytes };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chain Checkpoint and Step Replay Manager (#319)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class ChainCheckpointManager {
|
|
6
|
+
constructor(store) {
|
|
7
|
+
this.store = store;
|
|
8
|
+
this.activeRetries = new Set();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Records a snapshot of a completed or failed chain step
|
|
13
|
+
*/
|
|
14
|
+
recordStepSnapshot(chainRunId, stepTaskId, snapshot) {
|
|
15
|
+
if (!chainRunId || !stepTaskId) return;
|
|
16
|
+
this.store.chainCheckpoints = this.store.chainCheckpoints || {};
|
|
17
|
+
this.store.chainCheckpoints[chainRunId] = this.store.chainCheckpoints[chainRunId] || [];
|
|
18
|
+
|
|
19
|
+
const list = this.store.chainCheckpoints[chainRunId];
|
|
20
|
+
const existingIndex = list.findIndex((s) => s.stepTaskId === stepTaskId);
|
|
21
|
+
|
|
22
|
+
const record = {
|
|
23
|
+
chainRunId,
|
|
24
|
+
stepTaskId,
|
|
25
|
+
status: snapshot.status || 'completed',
|
|
26
|
+
input: snapshot.input || {},
|
|
27
|
+
output: snapshot.output || '',
|
|
28
|
+
at: Date.now(),
|
|
29
|
+
durationMs: snapshot.durationMs || 0,
|
|
30
|
+
retryCount: (existingIndex >= 0 ? (list[existingIndex].retryCount || 0) + 1 : 0),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
if (existingIndex >= 0) {
|
|
34
|
+
list[existingIndex] = record;
|
|
35
|
+
} else {
|
|
36
|
+
list.push(record);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.store.scheduleSave(50);
|
|
40
|
+
return record;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Retrieves all step snapshots for a given chain run
|
|
45
|
+
*/
|
|
46
|
+
getChainSnapshots(chainRunId) {
|
|
47
|
+
if (!chainRunId || !this.store.chainCheckpoints) return [];
|
|
48
|
+
return this.store.chainCheckpoints[chainRunId] || [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Checks whether a step can be retried in a chain run
|
|
53
|
+
*/
|
|
54
|
+
canRetryFromStep(chainRunId, stepTaskId) {
|
|
55
|
+
if (!chainRunId || !stepTaskId) return { ok: false, error: 'Missing chainRunId or stepTaskId' };
|
|
56
|
+
|
|
57
|
+
const lockKey = `${chainRunId}:${stepTaskId}`;
|
|
58
|
+
if (this.activeRetries.has(lockKey)) {
|
|
59
|
+
return { ok: false, error: 'Retry already in progress for this step' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const snapshots = this.getChainSnapshots(chainRunId);
|
|
63
|
+
const stepSnapshot = snapshots.find((s) => s.stepTaskId === stepTaskId);
|
|
64
|
+
if (!stepSnapshot) {
|
|
65
|
+
return { ok: false, error: `No checkpoint found for step "${stepTaskId}" in chain "${chainRunId}"` };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (stepSnapshot.status === 'success' || stepSnapshot.status === 'completed') {
|
|
69
|
+
return { ok: false, error: 'Step already completed successfully; cannot retry a successful step' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const task = this.store.get(stepTaskId);
|
|
73
|
+
if (!task) {
|
|
74
|
+
return { ok: false, error: `Task "${stepTaskId}" was deleted or no longer exists` };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { ok: true, task, stepSnapshot };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Retries chain execution from the specified failed step (#319)
|
|
82
|
+
* Reuses upstream outputs without re-executing successful upstream steps.
|
|
83
|
+
*/
|
|
84
|
+
async retryChainFromStep(scheduler, chainRunId, stepTaskId) {
|
|
85
|
+
const check = this.canRetryFromStep(chainRunId, stepTaskId);
|
|
86
|
+
if (!check.ok) return check;
|
|
87
|
+
|
|
88
|
+
const lockKey = `${chainRunId}:${stepTaskId}`;
|
|
89
|
+
this.activeRetries.add(lockKey);
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const snapshots = this.getChainSnapshots(chainRunId);
|
|
93
|
+
// Find the nearest successful preceding step snapshot
|
|
94
|
+
const stepIndex = snapshots.findIndex((s) => s.stepTaskId === stepTaskId);
|
|
95
|
+
let prevSnapshot = null;
|
|
96
|
+
for (let i = stepIndex - 1; i >= 0; i--) {
|
|
97
|
+
if (snapshots[i].status === 'success' || snapshots[i].status === 'completed') {
|
|
98
|
+
prevSnapshot = snapshots[i];
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const prevOutput = prevSnapshot ? prevSnapshot.output : '';
|
|
104
|
+
const prevTaskId = prevSnapshot ? prevSnapshot.stepTaskId : '';
|
|
105
|
+
|
|
106
|
+
// Execute only the failed step, passing reconstructed chain context
|
|
107
|
+
const runFn = (scheduler.runTask || scheduler.runNow).bind(scheduler);
|
|
108
|
+
const res = await runFn(stepTaskId, {
|
|
109
|
+
chainRunId,
|
|
110
|
+
chainPrevOutput: prevOutput,
|
|
111
|
+
chainPrevTaskId: prevTaskId,
|
|
112
|
+
isRetry: true,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
chainRunId,
|
|
118
|
+
stepTaskId,
|
|
119
|
+
result: res,
|
|
120
|
+
};
|
|
121
|
+
} finally {
|
|
122
|
+
this.activeRetries.delete(lockKey);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -140,8 +140,10 @@ window.__ModuleLoader__.load({
|
|
|
140
140
|
'history.stage_validation': 'Validation',
|
|
141
141
|
'history.stage_delivery': 'Delivery',
|
|
142
142
|
'history.refresh': 'Refresh history',
|
|
143
|
-
'history.
|
|
144
|
-
'history.
|
|
143
|
+
'history.statusSuccess': 'Success', 'history.statusUncertain': 'Uncertain',
|
|
144
|
+
'history.artifacts': 'Artifacts',
|
|
145
|
+
'history.resolveCompleted': 'Mark Completed', 'history.resolveFailed': 'Mark Failed',
|
|
146
|
+
'history.statusUncertainHint': 'Interrupted by restart — verify outcome',
|
|
145
147
|
'history.statusSkipped': 'Skipped',
|
|
146
148
|
'history.statusMissed': 'Missed',
|
|
147
149
|
'history.statusTimeout': 'Timeout',
|
|
@@ -361,11 +363,9 @@ window.__ModuleLoader__.load({
|
|
|
361
363
|
'form.targetSessionResetHint': 'Automatically rotates the session identifier to prevent context window explosion over long periods.',
|
|
362
364
|
'form.requireApprovalLabel': 'Require human approval before execution',
|
|
363
365
|
'form.requireApprovalHint': 'When enabled, the task will not execute until an authorized user approves the run proposal (#318).',
|
|
364
|
-
'actions.approve': 'Approve',
|
|
365
|
-
'actions.reject': 'Reject',
|
|
366
|
+
'actions.approve': 'Approve', 'actions.reject': 'Reject',
|
|
366
367
|
'approval.waiting': 'Awaiting human approval',
|
|
367
|
-
'approval.approved': 'Approved',
|
|
368
|
-
'approval.rejected': 'Rejected',
|
|
368
|
+
'approval.approved': 'Approved', 'approval.rejected': 'Rejected',
|
|
369
369
|
'recipe.dependenciesMissing': 'Missing required integrations or credentials',
|
|
370
370
|
'form.previewSchedule': 'Preview next runs',
|
|
371
371
|
'form.previewScheduleTitle': 'Next 5 scheduled runs:',
|
|
@@ -507,6 +507,10 @@ window.__ModuleLoader__.load({
|
|
|
507
507
|
'history.summaryCost': '预估成本: ${cost}',
|
|
508
508
|
'history.errorLabel': '错误详情:',
|
|
509
509
|
'history.outputLabel': '执行输出:',
|
|
510
|
+
'history.statusUncertain': '不确定',
|
|
511
|
+
'history.artifacts': '产物文件',
|
|
512
|
+
'history.resolveCompleted': '标记已完成', 'history.resolveFailed': '标记失败',
|
|
513
|
+
'history.statusUncertainHint': '服务重启中断 — 请确认结果',
|
|
510
514
|
'form.titleLabel': '任务名称 *',
|
|
511
515
|
'form.titlePlaceholder': '例如: 每周备份检查',
|
|
512
516
|
'form.typeLabel': '执行类型 *',
|
|
@@ -618,11 +622,9 @@ window.__ModuleLoader__.load({
|
|
|
618
622
|
'confirm.delete': '确定删除该任务吗?',
|
|
619
623
|
'form.requireApprovalLabel': '执行前需人工确认审批',
|
|
620
624
|
'form.requireApprovalHint': '开启后,任务执行前将挂起并等待授权用户批准提议 (#318)。',
|
|
621
|
-
'actions.approve': '批准',
|
|
622
|
-
'actions.reject': '拒绝',
|
|
625
|
+
'actions.approve': '批准', 'actions.reject': '拒绝',
|
|
623
626
|
'approval.waiting': '等待人工审批',
|
|
624
|
-
'approval.approved': '已批准',
|
|
625
|
-
'approval.rejected': '已拒绝',
|
|
627
|
+
'approval.approved': '已批准', 'approval.rejected': '已拒绝',
|
|
626
628
|
'recipe.dependenciesMissing': '缺少必要集成服务或凭据',
|
|
627
629
|
'list.loadErrorPrefix': '加载任务列表失败'
|
|
628
630
|
}
|
|
@@ -890,11 +892,6 @@ window.__ModuleLoader__.load({
|
|
|
890
892
|
const STYLE_HEAL_KEY = '__dshCronStyleHeal';
|
|
891
893
|
const STYLE_HEAL_INTERVAL_MS = 2000;
|
|
892
894
|
|
|
893
|
-
/**
|
|
894
|
-
* Keep the stylesheet attached: watch <head> (and <html>, which catches a
|
|
895
|
-
* replaced <head>) and re-attach on demand, with a slow interval as the
|
|
896
|
-
* backstop. heal() is idempotent and writes nothing while healthy.
|
|
897
|
-
*/
|
|
898
895
|
function startStyleSelfHeal() {
|
|
899
896
|
if (typeof document === 'undefined') return () => {};
|
|
900
897
|
if (typeof window !== 'undefined' && typeof window[STYLE_HEAL_KEY] === 'function') {
|
|
@@ -997,7 +994,6 @@ window.__ModuleLoader__.load({
|
|
|
997
994
|
const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
|
|
998
995
|
const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
|
|
999
996
|
|
|
1000
|
-
/** Collect every delivery key the settings endpoints may return. */
|
|
1001
997
|
function parseEnvText(text) {
|
|
1002
998
|
const env = {};
|
|
1003
999
|
for (const rawLine of String(text || '').split('\n')) {
|
|
@@ -1091,15 +1087,10 @@ window.__ModuleLoader__.load({
|
|
|
1091
1087
|
if (status === 'skipped') return T_KEY(t, 'history.statusSkipped');
|
|
1092
1088
|
if (status === 'missed') return T_KEY(t, 'history.statusMissed');
|
|
1093
1089
|
if (status === 'timeout') return T_KEY(t, 'history.statusTimeout');
|
|
1090
|
+
if (status === 'uncertain') return T_KEY(t, 'history.statusUncertain');
|
|
1094
1091
|
return T_KEY(t, 'history.statusFailed');
|
|
1095
1092
|
}
|
|
1096
1093
|
|
|
1097
|
-
/**
|
|
1098
|
-
* A config-declared task is owned by the profile config file (#50): the
|
|
1099
|
-
* panel shows it, but editing, pausing or deleting it here would be undone
|
|
1100
|
-
* at the next start, so the row keeps the source label instead of the
|
|
1101
|
-
* actions that cannot stick.
|
|
1102
|
-
*/
|
|
1103
1094
|
function isConfigManaged(task) {
|
|
1104
1095
|
return Boolean(task) && task.managedBy === 'config';
|
|
1105
1096
|
}
|
|
@@ -1107,10 +1098,6 @@ window.__ModuleLoader__.load({
|
|
|
1107
1098
|
return (typeof t === 'function' ? t : translate)(key, vars);
|
|
1108
1099
|
}
|
|
1109
1100
|
|
|
1110
|
-
/**
|
|
1111
|
-
* Issue #307: universal fetch with AbortController timeout (default 15s).
|
|
1112
|
-
* Connects with caller signal if supplied and fails closed on timeouts.
|
|
1113
|
-
*/
|
|
1114
1101
|
const fetchWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
|
|
1115
1102
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
1116
1103
|
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
@@ -1139,9 +1126,6 @@ window.__ModuleLoader__.load({
|
|
|
1139
1126
|
}
|
|
1140
1127
|
};
|
|
1141
1128
|
|
|
1142
|
-
/**
|
|
1143
|
-
* Issue #307: universal JSON fetch helper with timeout and aborted body protection.
|
|
1144
|
-
*/
|
|
1145
1129
|
const fetchJsonWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
|
|
1146
1130
|
const res = await fetchWithTimeout(url, options, timeoutMs);
|
|
1147
1131
|
let data = {};
|
|
@@ -1166,11 +1150,6 @@ window.__ModuleLoader__.load({
|
|
|
1166
1150
|
return out;
|
|
1167
1151
|
}
|
|
1168
1152
|
|
|
1169
|
-
/**
|
|
1170
|
-
* Shared delivery form used by both the task panel modal and the plugin
|
|
1171
|
-
* settings card, so a new channel is added in exactly one place.
|
|
1172
|
-
* Sections collapse via the canonical aria-expanded head contract.
|
|
1173
|
-
*/
|
|
1174
1153
|
function renderDeliverySettings({ T, values, onChange, onChannelTemplate, sections, onToggleSection }) {
|
|
1175
1154
|
const val = (key) => (values[key] === undefined || values[key] === null ? '' : String(values[key]));
|
|
1176
1155
|
const field = (f) => React.createElement('div', { className: 'dsh-cron-form-group', key: f.key, style: { marginBottom: '10px' } },
|
|
@@ -1247,7 +1226,6 @@ window.__ModuleLoader__.load({
|
|
|
1247
1226
|
];
|
|
1248
1227
|
}
|
|
1249
1228
|
|
|
1250
|
-
/** Parse a KEY=VALUE textarea into the task env object (#38). */
|
|
1251
1229
|
function CronScreen(props) {
|
|
1252
1230
|
const { ctx, toggle, onClose } = props;
|
|
1253
1231
|
const t = props.t || translate;
|
|
@@ -1460,7 +1438,6 @@ window.__ModuleLoader__.load({
|
|
|
1460
1438
|
return () => window.removeEventListener('keydown', onKey);
|
|
1461
1439
|
}, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
|
|
1462
1440
|
|
|
1463
|
-
/** #40: facet filtering happens client-side over the loaded list. */
|
|
1464
1441
|
const visibleTasks = React.useMemo(() => { let list = tasks;
|
|
1465
1442
|
if (filterType) list = list.filter((x) => (x.type || 'llm') === filterType);
|
|
1466
1443
|
if (filterModel) list = list.filter((x) => String(x.model || '').split('/').pop() === filterModel);
|
|
@@ -1491,7 +1468,6 @@ window.__ModuleLoader__.load({
|
|
|
1491
1468
|
setFilterChannel('');
|
|
1492
1469
|
};
|
|
1493
1470
|
|
|
1494
|
-
/** #34: scroll to and flash the task the sidebar list asked for. */
|
|
1495
1471
|
React.useEffect(() => {
|
|
1496
1472
|
if (!pendingTaskHighlight || tasks.length === 0) return;
|
|
1497
1473
|
const id = pendingTaskHighlight;
|
|
@@ -1718,7 +1694,6 @@ window.__ModuleLoader__.load({
|
|
|
1718
1694
|
}
|
|
1719
1695
|
};
|
|
1720
1696
|
|
|
1721
|
-
/** #41: duplicate a task; the server copies config, resets run state (#41). */
|
|
1722
1697
|
const handleDryRun = async (taskId) => {
|
|
1723
1698
|
try {
|
|
1724
1699
|
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/dry-run', { method: 'POST' });
|
|
@@ -1777,10 +1752,8 @@ window.__ModuleLoader__.load({
|
|
|
1777
1752
|
}
|
|
1778
1753
|
};
|
|
1779
1754
|
|
|
1780
|
-
/** A task id must not shadow a collection route (#42 review finding). */
|
|
1781
1755
|
const RESERVED_TASK_IDS = ['export', 'import'];
|
|
1782
1756
|
|
|
1783
|
-
/** Import needs the script-confirm header when the file has code tasks. */
|
|
1784
1757
|
const isCodeTaskForm = (x) => {
|
|
1785
1758
|
if (!x || typeof x !== 'object') return false;
|
|
1786
1759
|
if (CODE_FORM_TYPES.includes(x.type)) return true;
|
|
@@ -1797,7 +1770,6 @@ window.__ModuleLoader__.load({
|
|
|
1797
1770
|
return headers;
|
|
1798
1771
|
};
|
|
1799
1772
|
|
|
1800
|
-
/** #42, #240: download the task configuration with honest disclosure and sanitized export choice. */
|
|
1801
1773
|
const handleExport = async () => {
|
|
1802
1774
|
setTransferBusy(true);
|
|
1803
1775
|
try {
|
|
@@ -1827,7 +1799,6 @@ window.__ModuleLoader__.load({
|
|
|
1827
1799
|
}
|
|
1828
1800
|
};
|
|
1829
1801
|
|
|
1830
|
-
/** #42: read the picked file, validate it server-side and show the plan. */
|
|
1831
1802
|
const handleImportFile = async (e) => {
|
|
1832
1803
|
const file = e.target.files && e.target.files[0];
|
|
1833
1804
|
e.target.value = '';
|
|
@@ -1884,7 +1855,6 @@ window.__ModuleLoader__.load({
|
|
|
1884
1855
|
}
|
|
1885
1856
|
};
|
|
1886
1857
|
|
|
1887
|
-
/** #49: tune an existing task in a dialogue instead of editing fields. */
|
|
1888
1858
|
const handleTuneWithDsh = async (task) => {
|
|
1889
1859
|
const lines = [
|
|
1890
1860
|
`Tune the existing scheduled task "${task.title}" (id: ${task.id}).`,
|
|
@@ -2007,11 +1977,6 @@ window.__ModuleLoader__.load({
|
|
|
2007
1977
|
}
|
|
2008
1978
|
};
|
|
2009
1979
|
|
|
2010
|
-
/**
|
|
2011
|
-
* Update one delivery setting without dropping the rest of the object.
|
|
2012
|
-
* `undefined` removes the key so the server default applies again; an
|
|
2013
|
-
* empty string is kept so a text field can be cleared on purpose.
|
|
2014
|
-
*/
|
|
2015
1980
|
const updateDelivery = (key, value) => {
|
|
2016
1981
|
setSettingsDelivery((prev) => {
|
|
2017
1982
|
const next = Object.assign({}, prev);
|
|
@@ -2021,7 +1986,6 @@ window.__ModuleLoader__.load({
|
|
|
2021
1986
|
});
|
|
2022
1987
|
};
|
|
2023
1988
|
|
|
2024
|
-
/** Per-channel template overrides live in a nested map on the server. */
|
|
2025
1989
|
const updateChannelTemplate = (channelId, value) => {
|
|
2026
1990
|
setSettingsDelivery((prev) => {
|
|
2027
1991
|
const templates = Object.assign({}, prev.channelTemplates || {});
|
|
@@ -2195,6 +2159,23 @@ window.__ModuleLoader__.load({
|
|
|
2195
2159
|
}
|
|
2196
2160
|
};
|
|
2197
2161
|
|
|
2162
|
+
const handleResolveUncertain = async (taskId, runId, resolution) => {
|
|
2163
|
+
try {
|
|
2164
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/resolve-uncertain', {
|
|
2165
|
+
method: 'POST',
|
|
2166
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2167
|
+
body: JSON.stringify({ runId, resolution }),
|
|
2168
|
+
});
|
|
2169
|
+
if (data && data.ok) {
|
|
2170
|
+
await loadTaskHistory(taskId);
|
|
2171
|
+
} else {
|
|
2172
|
+
alert(data?.error || 'Failed to resolve uncertain run');
|
|
2173
|
+
}
|
|
2174
|
+
} catch (e) {
|
|
2175
|
+
alert(e.message || 'Error resolving uncertain run');
|
|
2176
|
+
}
|
|
2177
|
+
};
|
|
2178
|
+
|
|
2198
2179
|
const loadTaskHistory = async (taskId) => {
|
|
2199
2180
|
setHistoryLoading(true);
|
|
2200
2181
|
try {
|
|
@@ -2828,6 +2809,41 @@ window.__ModuleLoader__.load({
|
|
|
2828
2809
|
onClick: () => handleRetryDelivery(formId, run.id)
|
|
2829
2810
|
}, retryingDeliveryId === run.id ? T_KEY(t, 'history.retryingDelivery') : T_KEY(t, 'history.retryDelivery'))
|
|
2830
2811
|
),
|
|
2812
|
+
run.artifacts && run.artifacts.length > 0 && React.createElement('div', {
|
|
2813
|
+
style: { display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '6px', alignItems: 'center' }
|
|
2814
|
+
},
|
|
2815
|
+
React.createElement('span', { className: 'dsh-cron-hint', style: { fontSize: '11px' } }, '📦 ' + T_KEY(t, 'history.artifacts') + ':'),
|
|
2816
|
+
run.artifacts.map((art) => React.createElement('a', {
|
|
2817
|
+
key: art.name || art.id,
|
|
2818
|
+
href: '/dsh-cron/runs/' + encodeURIComponent(run.id) + '/artifacts/' + encodeURIComponent(art.name) + '/preview',
|
|
2819
|
+
target: '_blank',
|
|
2820
|
+
rel: 'noopener noreferrer',
|
|
2821
|
+
className: 'dsh-cron-btn-secondary',
|
|
2822
|
+
style: { padding: '2px 8px', fontSize: '11px', textDecoration: 'none' },
|
|
2823
|
+
title: art.name + ' (' + Math.round((art.size || 0) / 1024 * 10) / 10 + ' KB)'
|
|
2824
|
+
}, art.name))
|
|
2825
|
+
),
|
|
2826
|
+
run.status === 'uncertain' && React.createElement('div', {
|
|
2827
|
+
className: 'dsh-cron-status-banner',
|
|
2828
|
+
'data-type': 'warning',
|
|
2829
|
+
style: { marginTop: '8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }
|
|
2830
|
+
},
|
|
2831
|
+
React.createElement('span', null, '⚠️ ' + T_KEY(t, 'history.statusUncertainHint')),
|
|
2832
|
+
React.createElement('div', { style: { display: 'flex', gap: '6px' } },
|
|
2833
|
+
React.createElement('button', {
|
|
2834
|
+
type: 'button',
|
|
2835
|
+
className: 'dsh-cron-btn-secondary',
|
|
2836
|
+
style: { padding: '2px 8px', fontSize: '11px' },
|
|
2837
|
+
onClick: () => handleResolveUncertain(formId, run.id, 'completed')
|
|
2838
|
+
}, '✓ ' + T_KEY(t, 'history.resolveCompleted')),
|
|
2839
|
+
React.createElement('button', {
|
|
2840
|
+
type: 'button',
|
|
2841
|
+
className: 'dsh-cron-btn-secondary',
|
|
2842
|
+
style: { padding: '2px 8px', fontSize: '11px' },
|
|
2843
|
+
onClick: () => handleResolveUncertain(formId, run.id, 'failed')
|
|
2844
|
+
}, '✕ ' + T_KEY(t, 'history.resolveFailed'))
|
|
2845
|
+
)
|
|
2846
|
+
),
|
|
2831
2847
|
run.diagnosis && React.createElement('div', {
|
|
2832
2848
|
className: 'dsh-cron-status-banner',
|
|
2833
2849
|
'data-type': 'info',
|
|
@@ -3351,7 +3367,7 @@ window.__ModuleLoader__.load({
|
|
|
3351
3367
|
React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.silentRuleHint'))
|
|
3352
3368
|
)
|
|
3353
3369
|
),
|
|
3354
|
-
|
|
3370
|
+
|
|
3355
3371
|
React.createElement('div', {
|
|
3356
3372
|
style: {
|
|
3357
3373
|
marginTop: '16px',
|
|
@@ -3485,7 +3501,6 @@ window.__ModuleLoader__.load({
|
|
|
3485
3501
|
)
|
|
3486
3502
|
),
|
|
3487
3503
|
|
|
3488
|
-
|
|
3489
3504
|
dryRunResult && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setDryRunResult(null) },
|
|
3490
3505
|
React.createElement('div', {
|
|
3491
3506
|
className: 'dsh-cron-modal',
|
|
@@ -4167,18 +4182,9 @@ window.__ModuleLoader__.load({
|
|
|
4167
4182
|
return entry;
|
|
4168
4183
|
}
|
|
4169
4184
|
|
|
4170
|
-
/**
|
|
4171
|
-
* Collapsible list of active jobs under the sidebar entry (#34).
|
|
4172
|
-
*
|
|
4173
|
-
* Vanilla DOM like the entry itself: the sidebar is not a React root here.
|
|
4174
|
-
* Collapsed by default, state persisted, capped at a few rows with an
|
|
4175
|
-
* "and N more" line so a busy schedule cannot push the rest of the sidebar
|
|
4176
|
-
* off screen. Clicking a row opens the panel and highlights that task.
|
|
4177
|
-
*/
|
|
4178
4185
|
const SIDEBAR_JOBS_KEY = 'dsh-cron-sidebar-open';
|
|
4179
4186
|
const SIDEBAR_JOBS_MAX_ROWS = 5;
|
|
4180
4187
|
|
|
4181
|
-
/** Sidebar job row: title plus next run time or the live running state. */
|
|
4182
4188
|
function buildSidebarJobRow(state, task) {
|
|
4183
4189
|
const row = document.createElement('button');
|
|
4184
4190
|
row.type = 'button';
|
|
@@ -4274,7 +4280,6 @@ window.__ModuleLoader__.load({
|
|
|
4274
4280
|
return root;
|
|
4275
4281
|
}
|
|
4276
4282
|
|
|
4277
|
-
/** Keep the section mounted under the sidebar entry across re-renders. */
|
|
4278
4283
|
function placeSidebarJobs(state, label) {
|
|
4279
4284
|
if (state.root !== undefined && state.root.isConnected) return;
|
|
4280
4285
|
const entry = document.querySelector('[' + ENTRY_ATTR + ']');
|
|
@@ -4290,14 +4295,6 @@ window.__ModuleLoader__.load({
|
|
|
4290
4295
|
applySidebarJobsOpen(state);
|
|
4291
4296
|
}
|
|
4292
4297
|
|
|
4293
|
-
/**
|
|
4294
|
-
* Collapsible list of active jobs under the sidebar entry (#34).
|
|
4295
|
-
*
|
|
4296
|
-
* Vanilla DOM like the entry itself: the sidebar is not a React root here.
|
|
4297
|
-
* Collapsed by default, state persisted, capped at a few rows with an
|
|
4298
|
-
* "and N more" line so a busy schedule cannot push the rest of the sidebar
|
|
4299
|
-
* off screen. Clicking a row opens the panel and highlights that task.
|
|
4300
|
-
*/
|
|
4301
4298
|
function mountSidebarJobs(toggle, label, openTaskSignal) {
|
|
4302
4299
|
if (typeof document === 'undefined') return () => {};
|
|
4303
4300
|
const state = { toggle, openTaskSignal, root: undefined, listEl: undefined, headEl: undefined, open: false };
|
|
@@ -4437,12 +4434,6 @@ window.__ModuleLoader__.load({
|
|
|
4437
4434
|
};
|
|
4438
4435
|
}
|
|
4439
4436
|
|
|
4440
|
-
/**
|
|
4441
|
-
* Register a card into a DSH mount point. Mount points are populated via
|
|
4442
|
-
* ctx.slots.inject(mount, cb) — a direct slots.register does not throw but
|
|
4443
|
-
* also never appears in the target surface (settings plugins tab, header),
|
|
4444
|
-
* so the inject contract is mandatory (#85).
|
|
4445
|
-
*/
|
|
4446
4437
|
function registerIntoMount(ctx, mountName, entry, component) {
|
|
4447
4438
|
if (!ctx || !ctx.slots || typeof ctx.slots.inject !== 'function') return false;
|
|
4448
4439
|
try {
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* States for durable task execution lifecycle (#317)
|
|
5
|
+
*/
|
|
6
|
+
export const JOURNAL_STATES = {
|
|
7
|
+
QUEUED: 'queued',
|
|
8
|
+
RUNNING: 'running',
|
|
9
|
+
COMPLETED: 'completed',
|
|
10
|
+
FAILED: 'failed',
|
|
11
|
+
CANCELLED: 'cancelled',
|
|
12
|
+
UNCERTAIN: 'uncertain',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export class DurableJournal {
|
|
16
|
+
constructor(store) {
|
|
17
|
+
this.store = store;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generates a deterministic idempotency key for a task run
|
|
22
|
+
*/
|
|
23
|
+
generateIdempotencyKey(taskId, scheduleTick) {
|
|
24
|
+
const raw = `${taskId}:${scheduleTick || Date.now()}`;
|
|
25
|
+
return 'idem_' + createHash('sha256').update(raw).digest('hex').slice(0, 24);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Checks whether a run with this idempotency key was already completed
|
|
30
|
+
*/
|
|
31
|
+
isIdempotencyKeyExecuted(taskId, idempotencyKey) {
|
|
32
|
+
if (!idempotencyKey) return null;
|
|
33
|
+
const history = this.store.getHistory(taskId) || [];
|
|
34
|
+
return history.find(
|
|
35
|
+
(r) => r.idempotencyKey === idempotencyKey &&
|
|
36
|
+
(r.status === JOURNAL_STATES.COMPLETED || r.status === 'success')
|
|
37
|
+
) || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pre-allocates a durable run entry before execution starts (#317)
|
|
42
|
+
*/
|
|
43
|
+
createRunRecord(taskId, { idempotencyKey, chainRunId, trigger = 'scheduler', scheduleTick } = {}) {
|
|
44
|
+
const runId = 'run_' + randomUUID().replace(/-/g, '').slice(0, 16);
|
|
45
|
+
const key = idempotencyKey || this.generateIdempotencyKey(taskId, scheduleTick);
|
|
46
|
+
|
|
47
|
+
const record = {
|
|
48
|
+
id: runId,
|
|
49
|
+
taskId,
|
|
50
|
+
at: Date.now(),
|
|
51
|
+
status: JOURNAL_STATES.QUEUED,
|
|
52
|
+
trigger,
|
|
53
|
+
idempotencyKey: key,
|
|
54
|
+
chainRunId: chainRunId || null,
|
|
55
|
+
stages: [
|
|
56
|
+
{ name: 'queue', status: 'success', startedAt: Date.now(), finishedAt: Date.now() },
|
|
57
|
+
{ name: 'preflight', status: 'pending' },
|
|
58
|
+
{ name: 'execution', status: 'pending' },
|
|
59
|
+
{ name: 'validation', status: 'pending' },
|
|
60
|
+
{ name: 'delivery', status: 'pending' },
|
|
61
|
+
],
|
|
62
|
+
stoppedReason: null,
|
|
63
|
+
createdAt: Date.now(),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Record initial queued entry into history/store
|
|
67
|
+
this.store.recordRun(taskId, record);
|
|
68
|
+
return record;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Transitions a run record to a new state and updates history entry
|
|
73
|
+
*/
|
|
74
|
+
transition(taskId, runId, toState, metadata = {}) {
|
|
75
|
+
const runs = this.store.history.get(taskId) || [];
|
|
76
|
+
const index = runs.findIndex((r) => r.id === runId);
|
|
77
|
+
if (index === -1) return null;
|
|
78
|
+
|
|
79
|
+
const existing = runs[index];
|
|
80
|
+
const updated = {
|
|
81
|
+
...existing,
|
|
82
|
+
...metadata,
|
|
83
|
+
status: toState,
|
|
84
|
+
updatedAt: Date.now(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (
|
|
88
|
+
toState === JOURNAL_STATES.COMPLETED ||
|
|
89
|
+
toState === JOURNAL_STATES.FAILED ||
|
|
90
|
+
toState === JOURNAL_STATES.CANCELLED ||
|
|
91
|
+
toState === JOURNAL_STATES.UNCERTAIN
|
|
92
|
+
) {
|
|
93
|
+
updated.finishedAt = Date.now();
|
|
94
|
+
if (updated.startedAt && !updated.durationMs) {
|
|
95
|
+
updated.durationMs = updated.finishedAt - updated.startedAt;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
runs[index] = updated;
|
|
100
|
+
this.store.history.set(taskId, runs);
|
|
101
|
+
this.store.scheduleSave(50);
|
|
102
|
+
return updated;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Recovers uncompleted / in-flight runs on daemon restart (#317)
|
|
107
|
+
*/
|
|
108
|
+
recoverPendingRuns(scheduler) {
|
|
109
|
+
const tasks = this.store.list();
|
|
110
|
+
const recovered = { uncertain: [], requeued: [] };
|
|
111
|
+
|
|
112
|
+
for (const task of tasks) {
|
|
113
|
+
const history = this.store.history.get(task.id) || [];
|
|
114
|
+
let historyChanged = false;
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < history.length; i++) {
|
|
117
|
+
const run = history[i];
|
|
118
|
+
if (run.status === JOURNAL_STATES.RUNNING) {
|
|
119
|
+
// Crash during execution -> transition to UNCERTAIN to prevent blind rerun
|
|
120
|
+
history[i] = {
|
|
121
|
+
...run,
|
|
122
|
+
status: JOURNAL_STATES.UNCERTAIN,
|
|
123
|
+
stoppedReason: 'interrupted_by_restart',
|
|
124
|
+
uncertainAt: Date.now(),
|
|
125
|
+
};
|
|
126
|
+
historyChanged = true;
|
|
127
|
+
recovered.uncertain.push({ taskId: task.id, runId: run.id });
|
|
128
|
+
} else if (run.status === JOURNAL_STATES.QUEUED) {
|
|
129
|
+
// Queued run that didn't start yet: if task is still active and recent (<1 hour), re-enqueue
|
|
130
|
+
const ageMs = Date.now() - (run.createdAt || run.at);
|
|
131
|
+
if (task.status === 'active' && ageMs < 3600000 && scheduler && typeof scheduler.runTask === 'function') {
|
|
132
|
+
scheduler.queue = scheduler.queue || [];
|
|
133
|
+
if (!scheduler.queue.includes(task.id)) {
|
|
134
|
+
scheduler.queue.push(task.id);
|
|
135
|
+
recovered.requeued.push({ taskId: task.id, runId: run.id });
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
// Expired or cancelled queued run
|
|
139
|
+
history[i] = {
|
|
140
|
+
...run,
|
|
141
|
+
status: JOURNAL_STATES.CANCELLED,
|
|
142
|
+
stoppedReason: 'Queued run expired or cancelled across restart',
|
|
143
|
+
};
|
|
144
|
+
historyChanged = true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (historyChanged) {
|
|
150
|
+
this.store.scheduleSave(50);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return recovered;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Manually resolves an uncertain run record (#317)
|
|
159
|
+
*/
|
|
160
|
+
resolveUncertain(taskId, runId, { decision, note, resolvedBy = 'user' } = {}) {
|
|
161
|
+
const runs = this.store.history.get(taskId) || [];
|
|
162
|
+
const index = runs.findIndex((r) => r.id === runId);
|
|
163
|
+
if (index === -1) return { ok: false, error: 'Run not found' };
|
|
164
|
+
|
|
165
|
+
const run = runs[index];
|
|
166
|
+
if (run.status !== JOURNAL_STATES.UNCERTAIN) {
|
|
167
|
+
return { ok: false, error: `Run is in status "${run.status}", not uncertain` };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let nextStatus = JOURNAL_STATES.FAILED;
|
|
171
|
+
if (decision === 'completed' || decision === 'mark_completed') nextStatus = JOURNAL_STATES.COMPLETED;
|
|
172
|
+
else if (decision === 'failed' || decision === 'mark_failed') nextStatus = JOURNAL_STATES.FAILED;
|
|
173
|
+
else if (decision === 'cancel' || decision === 'cancelled') nextStatus = JOURNAL_STATES.CANCELLED;
|
|
174
|
+
|
|
175
|
+
const resolved = {
|
|
176
|
+
...run,
|
|
177
|
+
status: nextStatus,
|
|
178
|
+
stoppedReason: `Uncertain run resolved by ${resolvedBy}: ${note || decision}`,
|
|
179
|
+
resolvedBy,
|
|
180
|
+
resolvedAt: Date.now(),
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
runs[index] = resolved;
|
|
184
|
+
this.store.history.set(taskId, runs);
|
|
185
|
+
this.store.scheduleSave(50);
|
|
186
|
+
return { ok: true, run: resolved };
|
|
187
|
+
}
|
|
188
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -44,6 +44,21 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
44
44
|
handler: createCronApiHandler(store, scheduler, null)
|
|
45
45
|
}), 'dsh-cron: /schedule[...]');
|
|
46
46
|
|
|
47
|
+
|
|
48
|
+
// Chain retry from failed step (#319)
|
|
49
|
+
ctx.effect(() => ctx.webServer.register({
|
|
50
|
+
kind: 'prefix',
|
|
51
|
+
path: '/dsh-cron/chains',
|
|
52
|
+
handler: (req, res) => handleChainRoutes(store, scheduler, req, res)
|
|
53
|
+
}), 'dsh-cron: /chains[...]');
|
|
54
|
+
|
|
55
|
+
// Run artifacts listing, download and safe preview (#321)
|
|
56
|
+
ctx.effect(() => ctx.webServer.register({
|
|
57
|
+
kind: 'prefix',
|
|
58
|
+
path: '/dsh-cron/runs',
|
|
59
|
+
handler: (req, res) => handleRunArtifactRoutes(store, req, res)
|
|
60
|
+
}), 'dsh-cron: /runs[...]');
|
|
61
|
+
|
|
47
62
|
// GET /dsh-cron/metrics (#53) — Prometheus text exposition
|
|
48
63
|
ctx.effect(() => ctx.webServer.register({
|
|
49
64
|
kind: 'exact',
|
|
@@ -198,3 +213,72 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
198
213
|
}), 'dsh-cron: /kanban/test');
|
|
199
214
|
}
|
|
200
215
|
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Handler for chain step retries (#319)
|
|
219
|
+
*/
|
|
220
|
+
export async function handleChainRoutes(store, scheduler, req, res) {
|
|
221
|
+
if (req.method === 'POST' && rejectCrossOrigin(req, res)) return;
|
|
222
|
+
const url = new URL(req.url, 'http://localhost');
|
|
223
|
+
const m = url.pathname.match(/^\/dsh-cron\/chains\/([^\/]+)\/retry-from\/([^\/]+)$/);
|
|
224
|
+
if (!m) {
|
|
225
|
+
sendJson(res, 404, { ok: false, error: 'Route not found' });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (req.method !== 'POST') {
|
|
229
|
+
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const [, chainRunId, stepTaskId] = m;
|
|
233
|
+
const result = await store.chainCheckpointManager.retryChainFromStep(scheduler, chainRunId, stepTaskId);
|
|
234
|
+
if (!result.ok) {
|
|
235
|
+
sendJson(res, 400, result);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
sendJson(res, 200, result);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Handler for run artifacts (#321)
|
|
243
|
+
*/
|
|
244
|
+
export async function handleRunArtifactRoutes(store, req, res) {
|
|
245
|
+
const url = new URL(req.url, 'http://localhost');
|
|
246
|
+
const m = url.pathname.match(/^\/dsh-cron\/runs\/([^\/]+)\/artifacts(?:\/([^\/]+)(?:\/(preview))?)?$/);
|
|
247
|
+
if (!m) {
|
|
248
|
+
sendJson(res, 404, { ok: false, error: 'Route not found' });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (req.method !== 'GET') {
|
|
252
|
+
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const [, runId, artifactId, action] = m;
|
|
256
|
+
if (!artifactId) {
|
|
257
|
+
const artifacts = store.getArtifactsForRun(runId);
|
|
258
|
+
sendJson(res, 200, { ok: true, runId, artifacts });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const artifacts = store.getArtifactsForRun(runId);
|
|
262
|
+
const record = artifacts.find((a) => a.id === artifactId);
|
|
263
|
+
if (!record) {
|
|
264
|
+
sendJson(res, 404, { ok: false, error: 'Artifact record not found' });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (action === 'preview') {
|
|
268
|
+
const preview = store.artifactManager.generatePreview(artifactId, record.name);
|
|
269
|
+
sendJson(res, preview.ok ? 200 : 404, preview);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const buf = store.artifactManager.readArtifact(artifactId, record.name);
|
|
273
|
+
if (!buf) {
|
|
274
|
+
sendJson(res, 404, { ok: false, error: 'Artifact file missing from disk (expired or removed)' });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
res.writeHead(200, {
|
|
278
|
+
'Content-Type': record.mimeType || 'application/octet-stream',
|
|
279
|
+
'Content-Disposition': `attachment; filename="${encodeURIComponent(record.name)}"`,
|
|
280
|
+
'Content-Length': buf.length,
|
|
281
|
+
'X-Content-Type-Options': 'nosniff',
|
|
282
|
+
});
|
|
283
|
+
res.end(buf);
|
|
284
|
+
}
|
|
@@ -310,6 +310,23 @@ export async function executeTask(scheduler, taskId, options = {}) {
|
|
|
310
310
|
model: outcome.model || task.model || '',
|
|
311
311
|
};
|
|
312
312
|
}
|
|
313
|
+
// 0. Idempotency Check (#317): deduplicate runs with an already completed idempotency key
|
|
314
|
+
if (options.idempotencyKey && scheduler.store.durableJournal) {
|
|
315
|
+
const existing = scheduler.store.durableJournal.isIdempotencyKeyExecuted(taskId, options.idempotencyKey);
|
|
316
|
+
if (existing) {
|
|
317
|
+
logger.info(`[dsh-cron] Idempotency key "${options.idempotencyKey}" already executed for task "${taskId}", returning cached run`);
|
|
318
|
+
return {
|
|
319
|
+
ok: existing.status === 'completed' || existing.status === 'success',
|
|
320
|
+
idempotentReplay: true,
|
|
321
|
+
runId: existing.id,
|
|
322
|
+
status: existing.status,
|
|
323
|
+
output: existing.output,
|
|
324
|
+
costUsd: existing.costUsd,
|
|
325
|
+
durationMs: existing.durationMs,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
313
330
|
|
|
314
331
|
// 1. Approval Gate (#318): Human-in-the-loop check before executing actions requiring approval
|
|
315
332
|
if (task.requireApproval && !options.approvedBy) {
|
|
@@ -699,6 +716,46 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
699
716
|
kind: (Number(outcome.costUsd) > 0) ? 'exact' : ((task.type === 'llm' || task.type === 'skill' || task.type === 'workflow') ? 'unknown' : 'exact'),
|
|
700
717
|
};
|
|
701
718
|
runInfo.stoppedReason = computeStoppedReason(outcome.status, valRes, outcome.error);
|
|
719
|
+
// Durable journal & Idempotency (#317)
|
|
720
|
+
runInfo.idempotencyKey = options.idempotencyKey || currentRun?.idempotencyKey || null;
|
|
721
|
+
|
|
722
|
+
// Artifacts capture (#321)
|
|
723
|
+
if (Array.isArray(outcome.artifacts) && scheduler.store.artifactManager) {
|
|
724
|
+
for (const art of outcome.artifacts) {
|
|
725
|
+
if (art && art.name && (art.content || art.data)) {
|
|
726
|
+
try {
|
|
727
|
+
scheduler.store.artifactManager.saveArtifact(runInfo.runId, taskId, art.name, art.content || art.data);
|
|
728
|
+
} catch (artErr) {
|
|
729
|
+
logger.warn(`[dsh-cron] failed to save artifact ${art.name}:`, artErr.message);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (scheduler.store.getArtifactsForRun) {
|
|
735
|
+
runInfo.artifacts = scheduler.store.getArtifactsForRun(runInfo.runId);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Chain Checkpoints (#319)
|
|
739
|
+
const chainedTarget = outcome.status === 'success' ? task.onSuccess : task.onFailure;
|
|
740
|
+
const chainRunId = options.chainRunId || (chainedTarget ? ('chain_' + Math.random().toString(36).slice(2, 10)) : null);
|
|
741
|
+
if (chainRunId) {
|
|
742
|
+
runInfo.chainRunId = chainRunId;
|
|
743
|
+
if (scheduler.store.chainCheckpointManager) {
|
|
744
|
+
scheduler.store.chainCheckpointManager.recordStepSnapshot(chainRunId, taskId, {
|
|
745
|
+
status: outcome.status,
|
|
746
|
+
input: { prompt: task.prompt, prevOutput: options.prevOutput || options.chainPrevOutput || '' },
|
|
747
|
+
output: outcome.output || outcome.error || '',
|
|
748
|
+
durationMs: executionMs,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Update Durable Journal (#317)
|
|
754
|
+
if (scheduler.store.durableJournal) {
|
|
755
|
+
const journalState = outcome.status === 'success' ? 'completed' : (outcome.status === 'uncertain' ? 'uncertain' : 'failed');
|
|
756
|
+
scheduler.store.durableJournal.transition(taskId, runInfo.runId, journalState, runInfo);
|
|
757
|
+
}
|
|
758
|
+
|
|
702
759
|
|
|
703
760
|
try {
|
|
704
761
|
scheduler.store.recordRun(taskId, runInfo);
|
|
@@ -795,6 +852,7 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
795
852
|
if (nextTask) {
|
|
796
853
|
logger.info(`[dsh-cron] triggering chained task "${nextTask.title}" (${targetId}) from "${task.title}" (depth ${currentDepth + 1})`);
|
|
797
854
|
scheduler.runNow(targetId, {
|
|
855
|
+
chainRunId,
|
|
798
856
|
chainDepth: currentDepth + 1,
|
|
799
857
|
prevOutput: outcome.output || outcome.error || '',
|
|
800
858
|
prevTaskId: task.id,
|
package/lib/scheduler.js
CHANGED
|
@@ -361,6 +361,26 @@ export class TaskScheduler {
|
|
|
361
361
|
start() {
|
|
362
362
|
this.isStopped = false;
|
|
363
363
|
this.generation = (this.generation || 0) + 1;
|
|
364
|
+
|
|
365
|
+
// Recovery & cleanup on restart (#317, #321)
|
|
366
|
+
if (this.store && this.store.durableJournal) {
|
|
367
|
+
try {
|
|
368
|
+
const recovered = this.store.durableJournal.recoverPendingRuns(this);
|
|
369
|
+
if (recovered.uncertain.length > 0) {
|
|
370
|
+
logger.warn(`[dsh-cron] Recovered ${recovered.uncertain.length} in-flight run(s) interrupted by restart -> status marked uncertain`);
|
|
371
|
+
}
|
|
372
|
+
} catch (recErr) {
|
|
373
|
+
logger.error('[dsh-cron] restart recovery failed:', recErr.message);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (this.store && this.store.artifactManager) {
|
|
377
|
+
try {
|
|
378
|
+
this.store.artifactManager.cleanupArtifacts(this.store);
|
|
379
|
+
} catch (cleanErr) {
|
|
380
|
+
logger.warn('[dsh-cron] artifact cleanup failed:', cleanErr.message);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
364
384
|
const tasks = this.store.list();
|
|
365
385
|
const now = Date.now();
|
|
366
386
|
for (const task of tasks) {
|
package/lib/store.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { DurableJournal } from './durable-journal.js';
|
|
2
|
+
import { ChainCheckpointManager } from './chain-checkpoints.js';
|
|
3
|
+
import { ArtifactManager } from './artifacts.js';
|
|
1
4
|
import { logger } from './logger.js';
|
|
2
5
|
import { bestEffort } from './best-effort.js';
|
|
3
6
|
import fs from 'node:fs';
|
|
@@ -36,6 +39,12 @@ export class TaskStore {
|
|
|
36
39
|
this.tasks = new Map();
|
|
37
40
|
this.history = new Map();
|
|
38
41
|
this.settings = {};
|
|
42
|
+
this.chainCheckpoints = {};
|
|
43
|
+
this.artifacts = {};
|
|
44
|
+
const storeDir = path.dirname(this.filePath);
|
|
45
|
+
this.durableJournal = new DurableJournal(this);
|
|
46
|
+
this.chainCheckpointManager = new ChainCheckpointManager(this);
|
|
47
|
+
this.artifactManager = new ArtifactManager(path.join(storeDir, "artifacts"));
|
|
39
48
|
this._lastBackupTime = 0;
|
|
40
49
|
this._saveTimer = null;
|
|
41
50
|
this._savePromise = Promise.resolve();
|
|
@@ -85,6 +94,12 @@ export class TaskStore {
|
|
|
85
94
|
if (data.settings && typeof data.settings === 'object') {
|
|
86
95
|
this.settings = data.settings;
|
|
87
96
|
}
|
|
97
|
+
if (data.chainCheckpoints && typeof data.chainCheckpoints === 'object') {
|
|
98
|
+
this.chainCheckpoints = data.chainCheckpoints;
|
|
99
|
+
}
|
|
100
|
+
if (data.artifacts && typeof data.artifacts === 'object') {
|
|
101
|
+
this.artifacts = data.artifacts;
|
|
102
|
+
}
|
|
88
103
|
};
|
|
89
104
|
|
|
90
105
|
if (fs.existsSync(this.filePath)) {
|
|
@@ -189,6 +204,8 @@ export class TaskStore {
|
|
|
189
204
|
tasks: tasksSnapshot,
|
|
190
205
|
history: historySnapshot,
|
|
191
206
|
settings: this.settings ? { ...this.settings } : {},
|
|
207
|
+
chainCheckpoints: this.chainCheckpoints ? { ...this.chainCheckpoints } : {},
|
|
208
|
+
artifacts: this.artifacts ? { ...this.artifacts } : {},
|
|
192
209
|
};
|
|
193
210
|
}
|
|
194
211
|
|
|
@@ -347,7 +364,10 @@ export class TaskStore {
|
|
|
347
364
|
yield ']';
|
|
348
365
|
await new Promise((r) => setImmediate(r));
|
|
349
366
|
}
|
|
350
|
-
yield '}
|
|
367
|
+
yield '}';
|
|
368
|
+
yield ',"chainCheckpoints":' + JSON.stringify(data.chainCheckpoints || {});
|
|
369
|
+
yield ',"artifacts":' + JSON.stringify(data.artifacts || {});
|
|
370
|
+
yield '}';
|
|
351
371
|
}
|
|
352
372
|
|
|
353
373
|
try {
|
|
@@ -407,6 +427,32 @@ export class TaskStore {
|
|
|
407
427
|
}
|
|
408
428
|
}
|
|
409
429
|
|
|
430
|
+
getRun(runId) {
|
|
431
|
+
if (!runId) return null;
|
|
432
|
+
for (const [taskId, runs] of this.history.entries()) {
|
|
433
|
+
if (Array.isArray(runs)) {
|
|
434
|
+
const found = runs.find((r) => r.id === runId);
|
|
435
|
+
if (found) return { ...found, taskId };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
recordArtifact(runIdOrRecord, maybeRecord) {
|
|
442
|
+
const runId = maybeRecord ? runIdOrRecord : (runIdOrRecord && runIdOrRecord.runId);
|
|
443
|
+
const artifactRecord = maybeRecord ? maybeRecord : runIdOrRecord;
|
|
444
|
+
if (!runId || !artifactRecord) return;
|
|
445
|
+
this.artifacts = this.artifacts || {};
|
|
446
|
+
this.artifacts[runId] = this.artifacts[runId] || [];
|
|
447
|
+
this.artifacts[runId].push(artifactRecord);
|
|
448
|
+
this.scheduleSave(50);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
getArtifactsForRun(runId) {
|
|
452
|
+
if (!runId || !this.artifacts) return [];
|
|
453
|
+
return this.artifacts[runId] || [];
|
|
454
|
+
}
|
|
455
|
+
|
|
410
456
|
getSettings() {
|
|
411
457
|
const defaults = getDshDefaultTelegramCredentials();
|
|
412
458
|
const base = { ...this.settings };
|
|
@@ -706,6 +752,9 @@ export class TaskStore {
|
|
|
706
752
|
},
|
|
707
753
|
delivery: runInfo.delivery || null,
|
|
708
754
|
validation: runInfo.validation || null,
|
|
755
|
+
idempotencyKey: runInfo.idempotencyKey || null,
|
|
756
|
+
chainRunId: runInfo.chainRunId || null,
|
|
757
|
+
artifacts: runInfo.artifacts || null,
|
|
709
758
|
sessionId: runInfo.sessionId || null,
|
|
710
759
|
// #45: which model produced the run, and whether it only finished thanks
|
|
711
760
|
// to the configured fallback.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|