@goodandready/dsh-cron 0.2.52 → 0.3.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/lib/api.js +24 -0
- package/lib/artifacts.js +200 -0
- package/lib/chain-checkpoints.js +125 -0
- package/lib/client.js +89 -91
- package/lib/durable-journal.js +188 -0
- package/lib/prompt-interpolation.js +19 -0
- package/lib/routes.js +84 -0
- package/lib/runner.js +99 -1
- package/lib/scheduler-execution.js +58 -0
- package/lib/scheduler-lifecycle.js +6 -0
- package/lib/scheduler.js +35 -0
- package/lib/secrets.js +114 -0
- package/lib/settings.js +3 -1
- package/lib/store.js +50 -1
- package/lib/task-create.js +17 -0
- package/lib/task-patch.js +4 -0
- package/lib/task-transfer.js +1 -0
- package/lib/watcher.js +249 -0
- 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
|
+
}
|