@goodandready/dsh-cron 0.2.51 → 0.2.52
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 +36 -0
- package/lib/approval.js +143 -0
- package/lib/client.js +69 -9
- package/lib/cron-tool.js +4 -0
- package/lib/global-budgets.js +137 -0
- package/lib/recipes-catalog.js +107 -0
- package/lib/routes.js +3 -1
- package/lib/scheduler-execution.js +33 -1
- package/lib/settings.js +5 -0
- 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,41 @@ 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
|
+
}
|
|
374
410
|
if (action === 'run') {
|
|
375
411
|
if (!store.get(id)) {
|
|
376
412
|
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/client.js
CHANGED
|
@@ -140,14 +140,6 @@ window.__ModuleLoader__.load({
|
|
|
140
140
|
'history.stage_validation': 'Validation',
|
|
141
141
|
'history.stage_delivery': 'Delivery',
|
|
142
142
|
'history.refresh': 'Refresh history',
|
|
143
|
-
'history.stoppedReasonTitle': '停止原因:',
|
|
144
|
-
'history.retryDelivery': '重试推送',
|
|
145
|
-
'history.retryingDelivery': '重试中...',
|
|
146
|
-
'history.statusValidationFailed': '校验未通过',
|
|
147
|
-
'history.stage_queue': '排队',
|
|
148
|
-
'history.stage_preflight': '前置检查',
|
|
149
|
-
'history.stage_execution': '执行',
|
|
150
|
-
'history.stage_validation': '校验',
|
|
151
143
|
'history.stage_delivery': '推送',
|
|
152
144
|
'history.statusSuccess': 'Success',
|
|
153
145
|
'history.statusSkipped': 'Skipped',
|
|
@@ -367,6 +359,14 @@ window.__ModuleLoader__.load({
|
|
|
367
359
|
'form.targetSessionResetDaily': 'Daily rotation (new session each day)',
|
|
368
360
|
'form.targetSessionResetWeekly': 'Weekly rotation (new session each week)',
|
|
369
361
|
'form.targetSessionResetHint': 'Automatically rotates the session identifier to prevent context window explosion over long periods.',
|
|
362
|
+
'form.requireApprovalLabel': 'Require human approval before execution',
|
|
363
|
+
'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
|
+
'approval.waiting': 'Awaiting human approval',
|
|
367
|
+
'approval.approved': 'Approved',
|
|
368
|
+
'approval.rejected': 'Rejected',
|
|
369
|
+
'recipe.dependenciesMissing': 'Missing required integrations or credentials',
|
|
370
370
|
'form.previewSchedule': 'Preview next runs',
|
|
371
371
|
'form.previewScheduleTitle': 'Next 5 scheduled runs:',
|
|
372
372
|
},
|
|
@@ -616,6 +616,14 @@ window.__ModuleLoader__.load({
|
|
|
616
616
|
'dsh.startFailed': '启动智能体对话失败: {error}',
|
|
617
617
|
'dsh.launchError': '执行错误: {error}',
|
|
618
618
|
'confirm.delete': '确定删除该任务吗?',
|
|
619
|
+
'form.requireApprovalLabel': '执行前需人工确认审批',
|
|
620
|
+
'form.requireApprovalHint': '开启后,任务执行前将挂起并等待授权用户批准提议 (#318)。',
|
|
621
|
+
'actions.approve': '批准',
|
|
622
|
+
'actions.reject': '拒绝',
|
|
623
|
+
'approval.waiting': '等待人工审批',
|
|
624
|
+
'approval.approved': '已批准',
|
|
625
|
+
'approval.rejected': '已拒绝',
|
|
626
|
+
'recipe.dependenciesMissing': '缺少必要集成服务或凭据',
|
|
619
627
|
'list.loadErrorPrefix': '加载任务列表失败'
|
|
620
628
|
}
|
|
621
629
|
};
|
|
@@ -1272,6 +1280,7 @@ window.__ModuleLoader__.load({
|
|
|
1272
1280
|
const [formFallbackModel, setFormFallbackModel] = React.useState('');
|
|
1273
1281
|
const [formSilentRule, setFormSilentRule] = React.useState('');
|
|
1274
1282
|
const [formInspectOnFailure, setFormInspectOnFailure] = React.useState(false);
|
|
1283
|
+
const [formRequireApproval, setFormRequireApproval] = React.useState(false);
|
|
1275
1284
|
const [formDelivery, setFormDelivery] = React.useState('isolated');
|
|
1276
1285
|
const [selectedTaskMeta, setSelectedTaskMeta] = React.useState(null);
|
|
1277
1286
|
const [modalTab, setModalTab] = React.useState('params');
|
|
@@ -1341,6 +1350,7 @@ window.__ModuleLoader__.load({
|
|
|
1341
1350
|
fallbackModel: String(formFallbackModel || ''),
|
|
1342
1351
|
silentRule: String(formSilentRule || ''),
|
|
1343
1352
|
inspectOnFailure: Boolean(formInspectOnFailure),
|
|
1353
|
+
requireApproval: Boolean(formRequireApproval),
|
|
1344
1354
|
delivery: String(formDelivery || 'isolated'),
|
|
1345
1355
|
notifyTelegram: Boolean(formNotifyTelegram),
|
|
1346
1356
|
channels: Array.isArray(formChannels) ? [...formChannels].sort() : [],
|
|
@@ -1907,6 +1917,42 @@ window.__ModuleLoader__.load({
|
|
|
1907
1917
|
}
|
|
1908
1918
|
};
|
|
1909
1919
|
|
|
1920
|
+
const handleApproveProposal = async (taskId, proposalId) => {
|
|
1921
|
+
try {
|
|
1922
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/approve', {
|
|
1923
|
+
method: 'POST',
|
|
1924
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1925
|
+
body: JSON.stringify({ proposalId, decidedBy: 'web-user' })
|
|
1926
|
+
});
|
|
1927
|
+
if (data && data.ok) {
|
|
1928
|
+
alert(T_KEY(t, 'approval.approved') || 'Approved');
|
|
1929
|
+
setTimeout(() => fetchTasks(), 1000);
|
|
1930
|
+
} else {
|
|
1931
|
+
alert('Approval failed: ' + ((data && data.error) || ''));
|
|
1932
|
+
}
|
|
1933
|
+
} catch (err) {
|
|
1934
|
+
alert('Approval request failed: ' + err.message);
|
|
1935
|
+
}
|
|
1936
|
+
};
|
|
1937
|
+
|
|
1938
|
+
const handleRejectProposal = async (taskId, proposalId) => {
|
|
1939
|
+
try {
|
|
1940
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/reject', {
|
|
1941
|
+
method: 'POST',
|
|
1942
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1943
|
+
body: JSON.stringify({ proposalId, decidedBy: 'web-user' })
|
|
1944
|
+
});
|
|
1945
|
+
if (data && data.ok) {
|
|
1946
|
+
alert(T_KEY(t, 'approval.rejected') || 'Rejected');
|
|
1947
|
+
setTimeout(() => fetchTasks(), 1000);
|
|
1948
|
+
} else {
|
|
1949
|
+
alert('Rejection failed: ' + ((data && data.error) || ''));
|
|
1950
|
+
}
|
|
1951
|
+
} catch (err) {
|
|
1952
|
+
alert('Rejection request failed: ' + err.message);
|
|
1953
|
+
}
|
|
1954
|
+
};
|
|
1955
|
+
|
|
1910
1956
|
const handleDelete = async (id) => {
|
|
1911
1957
|
if (!confirm(T_KEY(t, 'confirm.delete'))) return;
|
|
1912
1958
|
try {
|
|
@@ -2049,6 +2095,7 @@ window.__ModuleLoader__.load({
|
|
|
2049
2095
|
setFormFallbackModel('');
|
|
2050
2096
|
setFormSilentRule('');
|
|
2051
2097
|
setFormInspectOnFailure(false);
|
|
2098
|
+
setFormRequireApproval(false);
|
|
2052
2099
|
if (preset) {
|
|
2053
2100
|
setFormTitle(preset.title || '');
|
|
2054
2101
|
setFormSchedule(preset.schedule || '');
|
|
@@ -2058,6 +2105,7 @@ window.__ModuleLoader__.load({
|
|
|
2058
2105
|
if (preset.silentRule !== undefined) setFormSilentRule(preset.silentRule || '');
|
|
2059
2106
|
if (preset.fallbackModel !== undefined) setFormFallbackModel(preset.fallbackModel || '');
|
|
2060
2107
|
if (preset.inspectOnFailure !== undefined) setFormInspectOnFailure(Boolean(preset.inspectOnFailure));
|
|
2108
|
+
if (preset.requireApproval !== undefined) setFormRequireApproval(Boolean(preset.requireApproval));
|
|
2061
2109
|
setFormOnlyOnFailure(Boolean(preset.onlyOnFailure));
|
|
2062
2110
|
setFormProvider(defaultModelInfo?.provider || '');
|
|
2063
2111
|
setFormModel(defaultModelInfo?.model || '');
|
|
@@ -2080,6 +2128,7 @@ window.__ModuleLoader__.load({
|
|
|
2080
2128
|
fallbackModel: String((preset && preset.fallbackModel) || '').trim(),
|
|
2081
2129
|
silentRule: String((preset && preset.silentRule) || '').trim(),
|
|
2082
2130
|
inspectOnFailure: Boolean(preset && preset.inspectOnFailure),
|
|
2131
|
+
requireApproval: Boolean(preset && preset.requireApproval),
|
|
2083
2132
|
delivery: 'isolated',
|
|
2084
2133
|
notifyTelegram: false,
|
|
2085
2134
|
channels: Array.isArray(preset && preset.channels) ? [...preset.channels].filter((c) => DELIVERY_CHANNELS.includes(c)).sort() : [],
|
|
@@ -2174,6 +2223,7 @@ window.__ModuleLoader__.load({
|
|
|
2174
2223
|
setFormFallbackModel(task.fallbackModel || '');
|
|
2175
2224
|
setFormSilentRule(task.silentRule || '');
|
|
2176
2225
|
setFormInspectOnFailure(Boolean(task.inspectOnFailure));
|
|
2226
|
+
setFormRequireApproval(Boolean(task.requireApproval));
|
|
2177
2227
|
setFormDelivery(task.delivery || 'isolated');
|
|
2178
2228
|
setFormNotifyTelegram(Boolean(task.notifyTelegram));
|
|
2179
2229
|
setFormChannels(Array.isArray(task.channels) ? task.channels.filter((c) => DELIVERY_CHANNELS.includes(c)) : []);
|
|
@@ -2271,6 +2321,7 @@ window.__ModuleLoader__.load({
|
|
|
2271
2321
|
concurrencyGroup: formConcurrencyGroup.trim() || 'default',
|
|
2272
2322
|
selfHealingCommand: formSelfHealingCommand.trim() || undefined,
|
|
2273
2323
|
autoDiagnose: Boolean(formAutoDiagnose),
|
|
2324
|
+
requireApproval: Boolean(formRequireApproval),
|
|
2274
2325
|
env: parseEnvText(formEnv),
|
|
2275
2326
|
cwd: formCwd.trim(),
|
|
2276
2327
|
workspaceId: formWorkspaceId.trim(),
|
|
@@ -3057,7 +3108,16 @@ window.__ModuleLoader__.load({
|
|
|
3057
3108
|
}),
|
|
3058
3109
|
React.createElement('span', null, T_KEY(t, 'form.inspectOnFailureLabel'))
|
|
3059
3110
|
),
|
|
3060
|
-
formInspectOnFailure && React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.inspectOnFailureHint'))
|
|
3111
|
+
formInspectOnFailure && React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.inspectOnFailureHint')),
|
|
3112
|
+
React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginTop: '8px' } },
|
|
3113
|
+
React.createElement('input', {
|
|
3114
|
+
type: 'checkbox',
|
|
3115
|
+
checked: formRequireApproval,
|
|
3116
|
+
onChange: (e) => setFormRequireApproval(e.target.checked)
|
|
3117
|
+
}),
|
|
3118
|
+
React.createElement('span', null, T_KEY(t, 'form.requireApprovalLabel'))
|
|
3119
|
+
),
|
|
3120
|
+
formRequireApproval && React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.requireApprovalHint'))
|
|
3061
3121
|
),
|
|
3062
3122
|
formType === 'http' && React.createElement('div', { className: 'dsh-cron-form-row' },
|
|
3063
3123
|
React.createElement('div', { className: 'dsh-cron-form-group' },
|
package/lib/cron-tool.js
CHANGED
|
@@ -152,6 +152,10 @@ export const cronToolParameters = {
|
|
|
152
152
|
enum: ['default', 'read-only', 'workspace-write', 'full'],
|
|
153
153
|
description: 'Permission preset applied to the task session (only used for agent session tasks: llm, skill, workflow)',
|
|
154
154
|
},
|
|
155
|
+
requireApproval: {
|
|
156
|
+
type: 'boolean',
|
|
157
|
+
description: 'Require explicit human approval before execution (#318)',
|
|
158
|
+
},
|
|
155
159
|
validators: {
|
|
156
160
|
type: 'array',
|
|
157
161
|
description: 'Declarative post-execution validation checks (json, regex, text, exitCode, file)',
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { logger } from './logger.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Global & Provider/Model Budgets and Rate Limits (#320).
|
|
5
|
+
* Manages shared expenditure windows and concurrent reservations
|
|
6
|
+
* across all scheduled tasks.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export class GlobalBudgetManager {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.reservations = new Map(); // reservationId -> { provider, model, costUsd, at }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Calculate total expenditure from store history across all tasks
|
|
16
|
+
* filtered by provider/model and window.
|
|
17
|
+
*/
|
|
18
|
+
calculateUsage(store, { provider = null, model = null, windowMs = 86400000, now = Date.now() } = {}) {
|
|
19
|
+
const cutoff = now - windowMs;
|
|
20
|
+
let totalCostUsd = 0;
|
|
21
|
+
let totalTokens = 0;
|
|
22
|
+
let runCount = 0;
|
|
23
|
+
|
|
24
|
+
const allTasks = store.list ? store.list() : [];
|
|
25
|
+
for (const task of allTasks) {
|
|
26
|
+
if (provider && task.provider && task.provider !== provider) continue;
|
|
27
|
+
if (model && task.model && task.model !== model) continue;
|
|
28
|
+
|
|
29
|
+
const runs = (store.history && store.history.get(task.id)) || [];
|
|
30
|
+
for (const r of runs) {
|
|
31
|
+
if (r && typeof r.at === 'number' && r.at >= cutoff) {
|
|
32
|
+
totalCostUsd += Number(r.costUsd) || 0;
|
|
33
|
+
if (typeof r.tokens === 'number') {
|
|
34
|
+
totalTokens += r.tokens;
|
|
35
|
+
} else if (r.usage) {
|
|
36
|
+
totalTokens += (r.usage.inputTokens || 0) + (r.usage.outputTokens || 0) + (r.usage.cacheReadTokens || 0);
|
|
37
|
+
}
|
|
38
|
+
runCount++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
totalCostUsd: Number(totalCostUsd.toFixed(6)),
|
|
45
|
+
totalTokens,
|
|
46
|
+
runCount,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check if global or provider budget limits are exceeded.
|
|
52
|
+
*/
|
|
53
|
+
checkBudget(store, task, settings = {}, now = Date.now()) {
|
|
54
|
+
// 1. Global Monthly / Daily Cost Limit
|
|
55
|
+
const globalDailyCostLimit = Number(settings.globalDailyCostLimitUsd) || 0;
|
|
56
|
+
if (globalDailyCostLimit > 0) {
|
|
57
|
+
const usage = this.calculateUsage(store, { windowMs: 86400000, now });
|
|
58
|
+
const pendingReservationsCost = this.getActiveReservationsCost();
|
|
59
|
+
if ((usage.totalCostUsd + pendingReservationsCost) >= globalDailyCostLimit) {
|
|
60
|
+
return {
|
|
61
|
+
exceeded: true,
|
|
62
|
+
type: 'globalDailyCostLimitUsd',
|
|
63
|
+
reason: `Global daily budget exceeded: $${(usage.totalCostUsd + pendingReservationsCost).toFixed(4)} >= $${globalDailyCostLimit.toFixed(4)}`,
|
|
64
|
+
current: usage.totalCostUsd,
|
|
65
|
+
limit: globalDailyCostLimit,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 2. Provider Rate Limit (max runs per minute / hour)
|
|
71
|
+
const provider = task.provider || 'default';
|
|
72
|
+
const providerHourlyLimit = (settings.providerHourlyRunLimits && Number(settings.providerHourlyRunLimits[provider])) || 0;
|
|
73
|
+
if (providerHourlyLimit > 0) {
|
|
74
|
+
const pUsage = this.calculateUsage(store, { provider, windowMs: 3600000, now });
|
|
75
|
+
if (pUsage.runCount >= providerHourlyLimit) {
|
|
76
|
+
return {
|
|
77
|
+
exceeded: true,
|
|
78
|
+
type: 'providerHourlyLimit',
|
|
79
|
+
reason: `Provider "${provider}" hourly run limit reached: ${pUsage.runCount} >= ${providerHourlyLimit}`,
|
|
80
|
+
current: pUsage.runCount,
|
|
81
|
+
limit: providerHourlyLimit,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3. Model Rate Limit
|
|
87
|
+
const model = task.model || 'default';
|
|
88
|
+
const modelHourlyLimit = (settings.modelHourlyRunLimits && Number(settings.modelHourlyRunLimits[model])) || 0;
|
|
89
|
+
if (modelHourlyLimit > 0) {
|
|
90
|
+
const mUsage = this.calculateUsage(store, { model, windowMs: 3600000, now });
|
|
91
|
+
if (mUsage.runCount >= modelHourlyLimit) {
|
|
92
|
+
return {
|
|
93
|
+
exceeded: true,
|
|
94
|
+
type: 'modelHourlyLimit',
|
|
95
|
+
reason: `Model "${model}" hourly run limit reached: ${mUsage.runCount} >= ${modelHourlyLimit}`,
|
|
96
|
+
current: mUsage.runCount,
|
|
97
|
+
limit: modelHourlyLimit,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { exceeded: false };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
reserve(task, estimatedCostUsd = 0.05) {
|
|
106
|
+
const id = 'res_' + Math.random().toString(36).slice(2, 9);
|
|
107
|
+
this.reservations.set(id, {
|
|
108
|
+
taskId: task.id,
|
|
109
|
+
provider: task.provider || 'default',
|
|
110
|
+
model: task.model || 'default',
|
|
111
|
+
costUsd: Number(estimatedCostUsd) || 0,
|
|
112
|
+
at: Date.now(),
|
|
113
|
+
});
|
|
114
|
+
return id;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
settle(reservationId) {
|
|
118
|
+
if (reservationId) {
|
|
119
|
+
this.reservations.delete(reservationId);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getActiveReservationsCost() {
|
|
124
|
+
let sum = 0;
|
|
125
|
+
const cutoff = Date.now() - 300000; // 5 min TTL on stale reservations
|
|
126
|
+
for (const [id, r] of this.reservations.entries()) {
|
|
127
|
+
if (r.at < cutoff) {
|
|
128
|
+
this.reservations.delete(id);
|
|
129
|
+
} else {
|
|
130
|
+
sum += r.costUsd;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return sum;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const globalBudgetManager = new GlobalBudgetManager();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { RECIPES as BASE_RECIPES } from './recipes.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Enhanced Verified Recipes Catalog with guided parameters, dependencies, and test runs (#322).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const EXTENDED_RECIPES = [
|
|
8
|
+
...BASE_RECIPES,
|
|
9
|
+
{
|
|
10
|
+
id: 'recipe_gitea_issues_digest',
|
|
11
|
+
category: 'maintenance',
|
|
12
|
+
title: 'Gitea Issues Weekly Digest',
|
|
13
|
+
description: 'Summarises open bugs and feature issues from local Gitea and reports triage status.',
|
|
14
|
+
type: 'llm',
|
|
15
|
+
schedule: '0 9 * * 1',
|
|
16
|
+
scheduleText: 'Mondays at 09:00',
|
|
17
|
+
prompt: 'Query open Gitea issues. Group by priority and scope, highlight stale issues, and produce an actionable weekly triage digest.',
|
|
18
|
+
channels: ['telegram'],
|
|
19
|
+
silentRule: 'Stay silent when there are no new or updated issues in the past 7 days.',
|
|
20
|
+
dependencies: {
|
|
21
|
+
integrations: ['gitea'],
|
|
22
|
+
credentials: ['giteaTokenRef'],
|
|
23
|
+
},
|
|
24
|
+
parameters: [
|
|
25
|
+
{ name: 'repo', label: 'Repository (owner/name)', type: 'string', default: 'goodandready/dsh-cron' },
|
|
26
|
+
{ name: 'daysBack', label: 'Days back', type: 'number', default: 7 },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'recipe_security_dependency_audit',
|
|
31
|
+
category: 'security',
|
|
32
|
+
title: 'NPM Dependency Security Audit',
|
|
33
|
+
description: 'Runs npm audit in repository directory and alerts on critical or high vulnerabilities.',
|
|
34
|
+
type: 'script',
|
|
35
|
+
schedule: '0 6 * * 1',
|
|
36
|
+
scheduleText: 'Mondays at 06:00',
|
|
37
|
+
prompt: 'npm audit --audit-level=high 2>&1 || { echo "CRITICAL / HIGH vulnerabilities found!"; exit 1; }',
|
|
38
|
+
channels: ['telegram'],
|
|
39
|
+
silentRule: 'Stay silent when npm audit finds 0 high/critical vulnerabilities.',
|
|
40
|
+
dependencies: {
|
|
41
|
+
integrations: ['shell'],
|
|
42
|
+
},
|
|
43
|
+
parameters: [
|
|
44
|
+
{ name: 'cwd', label: 'Working Directory', type: 'string', default: '.' },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'recipe_model_cost_summary',
|
|
49
|
+
category: 'system',
|
|
50
|
+
title: 'Daily Model Spend & Token Telemetry',
|
|
51
|
+
description: 'Computes rolling 24h spend across all models and warns if daily cost exceeds threshold.',
|
|
52
|
+
type: 'llm',
|
|
53
|
+
schedule: '0 23 * * *',
|
|
54
|
+
scheduleText: 'Every day at 23:00',
|
|
55
|
+
prompt: 'Analyze token usage ledger across all active cron jobs. Provide cost breakdown per model and identify anomalies.',
|
|
56
|
+
channels: ['telegram'],
|
|
57
|
+
silentRule: 'Stay silent when total daily expenditure is under $0.10.',
|
|
58
|
+
dependencies: {
|
|
59
|
+
integrations: ['cron-store'],
|
|
60
|
+
},
|
|
61
|
+
parameters: [
|
|
62
|
+
{ name: 'thresholdUsd', label: 'Alert Threshold (USD)', type: 'number', default: 1.0 },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate recipe dependencies against host settings.
|
|
69
|
+
* Returns { ok: boolean, missing: string[] }
|
|
70
|
+
*/
|
|
71
|
+
export function validateRecipeDependencies(recipe, settings = {}) {
|
|
72
|
+
if (!recipe || !recipe.dependencies) return { ok: true, missing: [] };
|
|
73
|
+
const missing = [];
|
|
74
|
+
const deps = recipe.dependencies;
|
|
75
|
+
|
|
76
|
+
if (Array.isArray(deps.credentials)) {
|
|
77
|
+
for (const cred of deps.credentials) {
|
|
78
|
+
if (!settings[cred] && !settings[cred.replace('Ref', '')]) {
|
|
79
|
+
missing.push(`Credential: ${cred}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (Array.isArray(deps.integrations)) {
|
|
85
|
+
for (const integ of deps.integrations) {
|
|
86
|
+
if (integ === 'gitea' && !settings.giteaBaseUrl) {
|
|
87
|
+
missing.push('Integration: Gitea (giteaBaseUrl not configured)');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
ok: missing.length === 0,
|
|
94
|
+
missing,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function listVerifiedRecipes(settings = {}) {
|
|
99
|
+
return EXTENDED_RECIPES.map((r) => {
|
|
100
|
+
const depCheck = validateRecipeDependencies(r, settings);
|
|
101
|
+
return {
|
|
102
|
+
...r,
|
|
103
|
+
dependenciesValid: depCheck.ok,
|
|
104
|
+
missingDependencies: depCheck.missing,
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { listVerifiedRecipes } from './recipes-catalog.js';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { createCronApiHandler } from './api.js';
|
|
3
4
|
import { createMetricsHandler } from './metrics.js';
|
|
@@ -93,8 +94,9 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
93
94
|
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
94
95
|
return;
|
|
95
96
|
}
|
|
97
|
+
const settings = store.getSettings ? store.getSettings() : {};
|
|
96
98
|
const categories = recipesByCategory();
|
|
97
|
-
const recipes =
|
|
99
|
+
const recipes = listVerifiedRecipes(settings);
|
|
98
100
|
sendJson(res, 200, { ok: true, categories, recipes });
|
|
99
101
|
} catch (err) {
|
|
100
102
|
sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { approvalManager } from './approval.js';
|
|
2
|
+
import { globalBudgetManager } from './global-budgets.js';
|
|
1
3
|
import { validateRunResult } from './validators.js';
|
|
2
4
|
import { logger } from './logger.js';
|
|
3
5
|
import { checkAndApplyBurnGuard, checkTaskBudgetLimits } from './burn-guard.js';
|
|
@@ -309,6 +311,30 @@ export async function executeTask(scheduler, taskId, options = {}) {
|
|
|
309
311
|
};
|
|
310
312
|
}
|
|
311
313
|
|
|
314
|
+
// 1. Approval Gate (#318): Human-in-the-loop check before executing actions requiring approval
|
|
315
|
+
if (task.requireApproval && !options.approvedBy) {
|
|
316
|
+
let pending = approvalManager.getPendingProposalForTask(taskId);
|
|
317
|
+
if (!pending) {
|
|
318
|
+
pending = approvalManager.createProposal(taskId, null, {
|
|
319
|
+
actionType: 'run',
|
|
320
|
+
summary: `Execution of task "${task.title}" requires human approval`,
|
|
321
|
+
details: { prompt: task.prompt, type: task.type, model: task.model }
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
logger.info(`[dsh-cron] Task "${task.title}" (${taskId}) paused awaiting human approval (${pending.id})`);
|
|
325
|
+
scheduler.recordSkipped(taskId, `Awaiting human approval: proposal ${pending.id}`);
|
|
326
|
+
return { ok: false, status: 'waiting_approval', proposalId: pending.id, stoppedReason: 'Awaiting human approval' };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 2. Global & Provider/Model Budgets (#320)
|
|
330
|
+
const settings = typeof scheduler.store.getSettings === 'function' ? scheduler.store.getSettings() : {};
|
|
331
|
+
const globalBudget = globalBudgetManager.checkBudget(scheduler.store, task, settings);
|
|
332
|
+
if (globalBudget.exceeded) {
|
|
333
|
+
logger.warn(`[dsh-cron] Global budget limit blocked task "${task.title}" (${taskId}): ${globalBudget.reason}`);
|
|
334
|
+
scheduler.recordSkipped(taskId, `Budget limit reached: ${globalBudget.reason}`);
|
|
335
|
+
return { ok: false, status: 'error', error: globalBudget.reason, budgetBlocked: true };
|
|
336
|
+
}
|
|
337
|
+
|
|
312
338
|
// Pre-flight Burn Guard check (#181): prevent executing task if budget already breached
|
|
313
339
|
const runs = (scheduler.store.history && scheduler.store.history.get(taskId)) || [];
|
|
314
340
|
const guard = checkTaskBudgetLimits(task, runs);
|
|
@@ -339,7 +365,13 @@ export async function executeTask(scheduler, taskId, options = {}) {
|
|
|
339
365
|
}
|
|
340
366
|
|
|
341
367
|
const start = Date.now();
|
|
342
|
-
const
|
|
368
|
+
const reservationId = globalBudgetManager.reserve(task);
|
|
369
|
+
let outcome;
|
|
370
|
+
try {
|
|
371
|
+
outcome = await scheduler.executeWithFallback(task, currentRun.controller.signal, options);
|
|
372
|
+
} finally {
|
|
373
|
+
globalBudgetManager.settle(reservationId);
|
|
374
|
+
}
|
|
343
375
|
await scheduler.completeRun(task, taskId, currentRun, outcome, start, {
|
|
344
376
|
...options,
|
|
345
377
|
preflightDurationMs,
|
package/lib/settings.js
CHANGED
|
@@ -47,6 +47,10 @@ export const Config = z.object({
|
|
|
47
47
|
giteaLabels: z.any().default([]).description('Gitea label names or IDs for alert issues').volatile(),
|
|
48
48
|
giteaLabelIds: z.any().default({}).description('Mapping of label name to integer ID for Gitea issues').volatile(),
|
|
49
49
|
prReviewerEnabled: z.boolean().default(false).description('Enable automated PR Reviewer scheduled agent (#33)').volatile(),
|
|
50
|
+
// --- Global Budgets (#320) ---
|
|
51
|
+
globalDailyCostLimitUsd: z.number().default(0).description('Global daily expenditure limit in USD across all tasks (0 = unlimited)').volatile(),
|
|
52
|
+
providerHourlyRunLimits: z.object({}).default({}).description('Max runs per hour keyed by provider').volatile(),
|
|
53
|
+
modelHourlyRunLimits: z.object({}).default({}).description('Max runs per hour keyed by model').volatile(),
|
|
50
54
|
// --- Declarative jobs (#50, ADR-0001) ---
|
|
51
55
|
jobs: z.array(z.any()).default([]).description('Static jobs owned by the config; each entry needs id, title, schedule and (for agent types) prompt'),
|
|
52
56
|
// --- External REST API (#54, ADR-0001) ---
|
|
@@ -83,6 +87,7 @@ export const SETTINGS_SYNC_KEYS = [
|
|
|
83
87
|
'ttsBaseUrl',
|
|
84
88
|
'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef', 'giteaLabels', 'giteaLabelIds', 'prReviewerEnabled',
|
|
85
89
|
'apiToken',
|
|
90
|
+
'globalDailyCostLimitUsd', 'providerHourlyRunLimits', 'modelHourlyRunLimits',
|
|
86
91
|
];
|
|
87
92
|
|
|
88
93
|
/**
|
package/lib/task-create.js
CHANGED
|
@@ -74,6 +74,7 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
74
74
|
dailyCostLimitUsd: Number(args.dailyCostLimitUsd) > 0 ? Number(args.dailyCostLimitUsd) : undefined,
|
|
75
75
|
tokenLimit: Number(args.tokenLimit) > 0 ? Number(args.tokenLimit) : undefined,
|
|
76
76
|
validators: Array.isArray(args.validators) ? sanitizeValidators(args.validators) : undefined,
|
|
77
|
+
requireApproval: Boolean(args.requireApproval),
|
|
77
78
|
};
|
|
78
79
|
// Issue #310 / GH #5: strip any undefined properties before store.set()
|
|
79
80
|
for (const k of Object.keys(taskData)) {
|
package/lib/task-patch.js
CHANGED
|
@@ -87,6 +87,9 @@ export function buildTaskPatch(current, body) {
|
|
|
87
87
|
return { ok: false, error: err.message };
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
+
if (patch.requireApproval !== undefined) {
|
|
91
|
+
patch.requireApproval = Boolean(patch.requireApproval);
|
|
92
|
+
}
|
|
90
93
|
if (patch.validators !== undefined) {
|
|
91
94
|
patch.validators = Array.isArray(patch.validators) ? sanitizeValidators(patch.validators) : [];
|
|
92
95
|
}
|
package/lib/task-transfer.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.52",
|
|
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",
|