@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 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);
@@ -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
+ }