@goodandready/dsh-cron 0.2.51 → 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 CHANGED
@@ -1,3 +1,4 @@
1
+ import { approvalManager } from './approval.js';
1
2
  import { sanitizeValidators } from './validators.js';
2
3
  /**
3
4
  * REST surface of the plugin: the /dsh-cron/tasks and /dsh-cron/action route
@@ -371,6 +372,65 @@ async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
371
372
  sendJson(res, 200, { ok: true, delivery, run });
372
373
  return true;
373
374
  }
375
+ if (action === 'approve' || action === 'reject') {
376
+ const task = store.get(id);
377
+ if (!task) {
378
+ sendJson(res, 404, NOT_FOUND);
379
+ return true;
380
+ }
381
+ const body = await readBodyJson(req).catch(() => ({}));
382
+ const proposalId = body.proposalId || url.searchParams.get('proposalId');
383
+ const proposal = proposalId
384
+ ? approvalManager.getProposal(proposalId)
385
+ : approvalManager.getPendingProposalForTask(id);
386
+
387
+ if (!proposal) {
388
+ sendJson(res, 404, { ok: false, error: 'No pending approval proposal found for this task' });
389
+ return true;
390
+ }
391
+
392
+ const decision = action === 'approve' ? 'approve' : 'reject';
393
+ const result = approvalManager.decide(proposal.id, decision, {
394
+ decidedBy: body.decidedBy || 'web-user',
395
+ reason: body.reason || '',
396
+ });
397
+
398
+ if (!result.ok) {
399
+ sendJson(res, 400, result);
400
+ return true;
401
+ }
402
+
403
+ if (decision === 'approve') {
404
+ scheduler.runTask(id, { approvedBy: body.decidedBy || 'web-user' }).catch(() => {});
405
+ }
406
+
407
+ sendJson(res, 200, { ok: true, proposal: result.proposal });
408
+ return true;
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
+ }
374
434
  if (action === 'run') {
375
435
  if (!store.get(id)) {
376
436
  sendJson(res, 404, NOT_FOUND);
@@ -0,0 +1,143 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { logger } from './logger.js';
3
+
4
+ /**
5
+ * Human-in-the-Loop Action Approval Engine (#318).
6
+ * Manages action approval proposals before execution, ensuring
7
+ * no dangerous side effects execute without authorized human confirmation.
8
+ */
9
+
10
+ export const APPROVAL_STATES = {
11
+ PENDING: 'pending',
12
+ APPROVED: 'approved',
13
+ REJECTED: 'rejected',
14
+ EXPIRED: 'expired',
15
+ CANCELLED: 'cancelled',
16
+ };
17
+
18
+ /**
19
+ * Clean proposal object for storage and API responses.
20
+ */
21
+ export function sanitizeProposal(proposal) {
22
+ if (!proposal || typeof proposal !== 'object') return null;
23
+ return {
24
+ id: String(proposal.id || randomUUID()),
25
+ taskId: String(proposal.taskId || ''),
26
+ runId: proposal.runId ? String(proposal.runId) : null,
27
+ actionType: String(proposal.actionType || 'execute'),
28
+ summary: String(proposal.summary || ''),
29
+ details: proposal.details && typeof proposal.details === 'object' ? proposal.details : {},
30
+ requestedAt: Number(proposal.requestedAt) || Date.now(),
31
+ expiresAt: Number(proposal.expiresAt) || (Date.now() + 3600000), // Default 1 hour
32
+ state: Object.values(APPROVAL_STATES).includes(proposal.state) ? proposal.state : APPROVAL_STATES.PENDING,
33
+ decidedAt: proposal.decidedAt ? Number(proposal.decidedAt) : null,
34
+ decidedBy: proposal.decidedBy ? String(proposal.decidedBy) : null,
35
+ reason: proposal.reason ? String(proposal.reason) : null,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * In-memory / persistent registry for task proposals.
41
+ */
42
+ export class ApprovalManager {
43
+ constructor() {
44
+ this.proposals = new Map();
45
+ }
46
+
47
+ /**
48
+ * Create a new pending approval proposal for a task.
49
+ */
50
+ createProposal(taskId, runId, { actionType = 'execute', summary = '', details = {}, timeoutMs = 3600000 } = {}) {
51
+ // If an existing pending proposal exists for this task, invalidate it as superseded
52
+ for (const [id, prop] of this.proposals.entries()) {
53
+ if (prop.taskId === taskId && prop.state === APPROVAL_STATES.PENDING) {
54
+ prop.state = APPROVAL_STATES.CANCELLED;
55
+ prop.decidedAt = Date.now();
56
+ prop.reason = 'Superseded by newer proposal';
57
+ }
58
+ }
59
+
60
+ const proposal = sanitizeProposal({
61
+ id: 'appr_' + randomUUID().replace(/-/g, '').slice(0, 12),
62
+ taskId,
63
+ runId,
64
+ actionType,
65
+ summary,
66
+ details,
67
+ requestedAt: Date.now(),
68
+ expiresAt: Date.now() + (Number(timeoutMs) > 0 ? Number(timeoutMs) : 3600000),
69
+ state: APPROVAL_STATES.PENDING,
70
+ });
71
+
72
+ this.proposals.set(proposal.id, proposal);
73
+ logger.info(`[dsh-cron] created approval proposal "${proposal.id}" for task "${taskId}": ${summary}`);
74
+ return proposal;
75
+ }
76
+
77
+ getProposal(proposalId) {
78
+ const prop = this.proposals.get(proposalId);
79
+ if (!prop) return null;
80
+ // Check expiration on read
81
+ if (prop.state === APPROVAL_STATES.PENDING && Date.now() > prop.expiresAt) {
82
+ prop.state = APPROVAL_STATES.EXPIRED;
83
+ prop.decidedAt = Date.now();
84
+ prop.reason = 'Approval expired';
85
+ }
86
+ return prop;
87
+ }
88
+
89
+ getPendingProposalForTask(taskId) {
90
+ for (const prop of this.proposals.values()) {
91
+ if (prop.taskId === taskId && prop.state === APPROVAL_STATES.PENDING) {
92
+ if (Date.now() > prop.expiresAt) {
93
+ prop.state = APPROVAL_STATES.EXPIRED;
94
+ prop.decidedAt = Date.now();
95
+ prop.reason = 'Approval expired';
96
+ continue;
97
+ }
98
+ return prop;
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ listProposals({ taskId = null, state = null } = {}) {
105
+ const list = [];
106
+ const now = Date.now();
107
+ for (const prop of this.proposals.values()) {
108
+ if (prop.state === APPROVAL_STATES.PENDING && now > prop.expiresAt) {
109
+ prop.state = APPROVAL_STATES.EXPIRED;
110
+ prop.decidedAt = now;
111
+ prop.reason = 'Approval expired';
112
+ }
113
+ if (taskId && prop.taskId !== taskId) continue;
114
+ if (state && prop.state !== state) continue;
115
+ list.push({ ...prop });
116
+ }
117
+ return list.sort((a, b) => b.requestedAt - a.requestedAt);
118
+ }
119
+
120
+ /**
121
+ * Decide on a proposal (approve or reject) with replay and double-click protection.
122
+ */
123
+ decide(proposalId, decision, { decidedBy = 'user', reason = '' } = {}) {
124
+ const prop = this.getProposal(proposalId);
125
+ if (!prop) {
126
+ return { ok: false, error: 'Proposal not found' };
127
+ }
128
+ if (prop.state !== APPROVAL_STATES.PENDING) {
129
+ return { ok: false, error: `Proposal already resolved with state: ${prop.state}`, proposal: prop };
130
+ }
131
+
132
+ const state = decision === 'approve' ? APPROVAL_STATES.APPROVED : APPROVAL_STATES.REJECTED;
133
+ prop.state = state;
134
+ prop.decidedAt = Date.now();
135
+ prop.decidedBy = decidedBy;
136
+ prop.reason = reason;
137
+
138
+ logger.info(`[dsh-cron] proposal "${proposalId}" ${state} by ${decidedBy}${reason ? ': ' + reason : ''}`);
139
+ return { ok: true, proposal: prop };
140
+ }
141
+ }
142
+
143
+ export const approvalManager = new ApprovalManager();
@@ -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
+ }