@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 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);
@@ -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 = categories.reduce((acc, category) => acc.concat(category.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 outcome = await scheduler.executeWithFallback(task, currentRun.controller.signal, options);
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
  /**
@@ -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
  }
@@ -75,6 +75,7 @@ export const PATCHABLE_TASK_FIELDS = [
75
75
  'tokenLimit',
76
76
  'pausedReason',
77
77
  'validators',
78
+ 'requireApproval',
78
79
  ];
79
80
 
80
81
  /** Fields a duplicate inherits; anything else is runtime state, not config. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.51",
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",