@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 +60 -0
- package/lib/approval.js +143 -0
- package/lib/artifacts.js +200 -0
- package/lib/chain-checkpoints.js +125 -0
- package/lib/client.js +127 -76
- package/lib/cron-tool.js +4 -0
- package/lib/durable-journal.js +188 -0
- package/lib/global-budgets.js +137 -0
- package/lib/recipes-catalog.js +107 -0
- package/lib/routes.js +87 -1
- package/lib/scheduler-execution.js +91 -1
- package/lib/scheduler.js +20 -0
- package/lib/settings.js +5 -0
- package/lib/store.js +50 -1
- package/lib/task-create.js +1 -0
- package/lib/task-patch.js +3 -0
- package/lib/task-transfer.js +1 -0
- package/package.json +1 -1
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);
|
package/lib/approval.js
ADDED
|
@@ -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();
|
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
|
+
}
|