@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.
@@ -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';
@@ -43,6 +44,21 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
43
44
  handler: createCronApiHandler(store, scheduler, null)
44
45
  }), 'dsh-cron: /schedule[...]');
45
46
 
47
+
48
+ // Chain retry from failed step (#319)
49
+ ctx.effect(() => ctx.webServer.register({
50
+ kind: 'prefix',
51
+ path: '/dsh-cron/chains',
52
+ handler: (req, res) => handleChainRoutes(store, scheduler, req, res)
53
+ }), 'dsh-cron: /chains[...]');
54
+
55
+ // Run artifacts listing, download and safe preview (#321)
56
+ ctx.effect(() => ctx.webServer.register({
57
+ kind: 'prefix',
58
+ path: '/dsh-cron/runs',
59
+ handler: (req, res) => handleRunArtifactRoutes(store, req, res)
60
+ }), 'dsh-cron: /runs[...]');
61
+
46
62
  // GET /dsh-cron/metrics (#53) — Prometheus text exposition
47
63
  ctx.effect(() => ctx.webServer.register({
48
64
  kind: 'exact',
@@ -93,8 +109,9 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
93
109
  sendJson(res, 405, { ok: false, error: 'Method not allowed' });
94
110
  return;
95
111
  }
112
+ const settings = store.getSettings ? store.getSettings() : {};
96
113
  const categories = recipesByCategory();
97
- const recipes = categories.reduce((acc, category) => acc.concat(category.recipes), []);
114
+ const recipes = listVerifiedRecipes(settings);
98
115
  sendJson(res, 200, { ok: true, categories, recipes });
99
116
  } catch (err) {
100
117
  sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
@@ -196,3 +213,72 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
196
213
  }), 'dsh-cron: /kanban/test');
197
214
  }
198
215
 
216
+
217
+ /**
218
+ * Handler for chain step retries (#319)
219
+ */
220
+ export async function handleChainRoutes(store, scheduler, req, res) {
221
+ if (req.method === 'POST' && rejectCrossOrigin(req, res)) return;
222
+ const url = new URL(req.url, 'http://localhost');
223
+ const m = url.pathname.match(/^\/dsh-cron\/chains\/([^\/]+)\/retry-from\/([^\/]+)$/);
224
+ if (!m) {
225
+ sendJson(res, 404, { ok: false, error: 'Route not found' });
226
+ return;
227
+ }
228
+ if (req.method !== 'POST') {
229
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
230
+ return;
231
+ }
232
+ const [, chainRunId, stepTaskId] = m;
233
+ const result = await store.chainCheckpointManager.retryChainFromStep(scheduler, chainRunId, stepTaskId);
234
+ if (!result.ok) {
235
+ sendJson(res, 400, result);
236
+ return;
237
+ }
238
+ sendJson(res, 200, result);
239
+ }
240
+
241
+ /**
242
+ * Handler for run artifacts (#321)
243
+ */
244
+ export async function handleRunArtifactRoutes(store, req, res) {
245
+ const url = new URL(req.url, 'http://localhost');
246
+ const m = url.pathname.match(/^\/dsh-cron\/runs\/([^\/]+)\/artifacts(?:\/([^\/]+)(?:\/(preview))?)?$/);
247
+ if (!m) {
248
+ sendJson(res, 404, { ok: false, error: 'Route not found' });
249
+ return;
250
+ }
251
+ if (req.method !== 'GET') {
252
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
253
+ return;
254
+ }
255
+ const [, runId, artifactId, action] = m;
256
+ if (!artifactId) {
257
+ const artifacts = store.getArtifactsForRun(runId);
258
+ sendJson(res, 200, { ok: true, runId, artifacts });
259
+ return;
260
+ }
261
+ const artifacts = store.getArtifactsForRun(runId);
262
+ const record = artifacts.find((a) => a.id === artifactId);
263
+ if (!record) {
264
+ sendJson(res, 404, { ok: false, error: 'Artifact record not found' });
265
+ return;
266
+ }
267
+ if (action === 'preview') {
268
+ const preview = store.artifactManager.generatePreview(artifactId, record.name);
269
+ sendJson(res, preview.ok ? 200 : 404, preview);
270
+ return;
271
+ }
272
+ const buf = store.artifactManager.readArtifact(artifactId, record.name);
273
+ if (!buf) {
274
+ sendJson(res, 404, { ok: false, error: 'Artifact file missing from disk (expired or removed)' });
275
+ return;
276
+ }
277
+ res.writeHead(200, {
278
+ 'Content-Type': record.mimeType || 'application/octet-stream',
279
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(record.name)}"`,
280
+ 'Content-Length': buf.length,
281
+ 'X-Content-Type-Options': 'nosniff',
282
+ });
283
+ res.end(buf);
284
+ }
@@ -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';
@@ -308,6 +310,47 @@ export async function executeTask(scheduler, taskId, options = {}) {
308
310
  model: outcome.model || task.model || '',
309
311
  };
310
312
  }
313
+ // 0. Idempotency Check (#317): deduplicate runs with an already completed idempotency key
314
+ if (options.idempotencyKey && scheduler.store.durableJournal) {
315
+ const existing = scheduler.store.durableJournal.isIdempotencyKeyExecuted(taskId, options.idempotencyKey);
316
+ if (existing) {
317
+ logger.info(`[dsh-cron] Idempotency key "${options.idempotencyKey}" already executed for task "${taskId}", returning cached run`);
318
+ return {
319
+ ok: existing.status === 'completed' || existing.status === 'success',
320
+ idempotentReplay: true,
321
+ runId: existing.id,
322
+ status: existing.status,
323
+ output: existing.output,
324
+ costUsd: existing.costUsd,
325
+ durationMs: existing.durationMs,
326
+ };
327
+ }
328
+ }
329
+
330
+
331
+ // 1. Approval Gate (#318): Human-in-the-loop check before executing actions requiring approval
332
+ if (task.requireApproval && !options.approvedBy) {
333
+ let pending = approvalManager.getPendingProposalForTask(taskId);
334
+ if (!pending) {
335
+ pending = approvalManager.createProposal(taskId, null, {
336
+ actionType: 'run',
337
+ summary: `Execution of task "${task.title}" requires human approval`,
338
+ details: { prompt: task.prompt, type: task.type, model: task.model }
339
+ });
340
+ }
341
+ logger.info(`[dsh-cron] Task "${task.title}" (${taskId}) paused awaiting human approval (${pending.id})`);
342
+ scheduler.recordSkipped(taskId, `Awaiting human approval: proposal ${pending.id}`);
343
+ return { ok: false, status: 'waiting_approval', proposalId: pending.id, stoppedReason: 'Awaiting human approval' };
344
+ }
345
+
346
+ // 2. Global & Provider/Model Budgets (#320)
347
+ const settings = typeof scheduler.store.getSettings === 'function' ? scheduler.store.getSettings() : {};
348
+ const globalBudget = globalBudgetManager.checkBudget(scheduler.store, task, settings);
349
+ if (globalBudget.exceeded) {
350
+ logger.warn(`[dsh-cron] Global budget limit blocked task "${task.title}" (${taskId}): ${globalBudget.reason}`);
351
+ scheduler.recordSkipped(taskId, `Budget limit reached: ${globalBudget.reason}`);
352
+ return { ok: false, status: 'error', error: globalBudget.reason, budgetBlocked: true };
353
+ }
311
354
 
312
355
  // Pre-flight Burn Guard check (#181): prevent executing task if budget already breached
313
356
  const runs = (scheduler.store.history && scheduler.store.history.get(taskId)) || [];
@@ -339,7 +382,13 @@ export async function executeTask(scheduler, taskId, options = {}) {
339
382
  }
340
383
 
341
384
  const start = Date.now();
342
- const outcome = await scheduler.executeWithFallback(task, currentRun.controller.signal, options);
385
+ const reservationId = globalBudgetManager.reserve(task);
386
+ let outcome;
387
+ try {
388
+ outcome = await scheduler.executeWithFallback(task, currentRun.controller.signal, options);
389
+ } finally {
390
+ globalBudgetManager.settle(reservationId);
391
+ }
343
392
  await scheduler.completeRun(task, taskId, currentRun, outcome, start, {
344
393
  ...options,
345
394
  preflightDurationMs,
@@ -667,6 +716,46 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
667
716
  kind: (Number(outcome.costUsd) > 0) ? 'exact' : ((task.type === 'llm' || task.type === 'skill' || task.type === 'workflow') ? 'unknown' : 'exact'),
668
717
  };
669
718
  runInfo.stoppedReason = computeStoppedReason(outcome.status, valRes, outcome.error);
719
+ // Durable journal & Idempotency (#317)
720
+ runInfo.idempotencyKey = options.idempotencyKey || currentRun?.idempotencyKey || null;
721
+
722
+ // Artifacts capture (#321)
723
+ if (Array.isArray(outcome.artifacts) && scheduler.store.artifactManager) {
724
+ for (const art of outcome.artifacts) {
725
+ if (art && art.name && (art.content || art.data)) {
726
+ try {
727
+ scheduler.store.artifactManager.saveArtifact(runInfo.runId, taskId, art.name, art.content || art.data);
728
+ } catch (artErr) {
729
+ logger.warn(`[dsh-cron] failed to save artifact ${art.name}:`, artErr.message);
730
+ }
731
+ }
732
+ }
733
+ }
734
+ if (scheduler.store.getArtifactsForRun) {
735
+ runInfo.artifacts = scheduler.store.getArtifactsForRun(runInfo.runId);
736
+ }
737
+
738
+ // Chain Checkpoints (#319)
739
+ const chainedTarget = outcome.status === 'success' ? task.onSuccess : task.onFailure;
740
+ const chainRunId = options.chainRunId || (chainedTarget ? ('chain_' + Math.random().toString(36).slice(2, 10)) : null);
741
+ if (chainRunId) {
742
+ runInfo.chainRunId = chainRunId;
743
+ if (scheduler.store.chainCheckpointManager) {
744
+ scheduler.store.chainCheckpointManager.recordStepSnapshot(chainRunId, taskId, {
745
+ status: outcome.status,
746
+ input: { prompt: task.prompt, prevOutput: options.prevOutput || options.chainPrevOutput || '' },
747
+ output: outcome.output || outcome.error || '',
748
+ durationMs: executionMs,
749
+ });
750
+ }
751
+ }
752
+
753
+ // Update Durable Journal (#317)
754
+ if (scheduler.store.durableJournal) {
755
+ const journalState = outcome.status === 'success' ? 'completed' : (outcome.status === 'uncertain' ? 'uncertain' : 'failed');
756
+ scheduler.store.durableJournal.transition(taskId, runInfo.runId, journalState, runInfo);
757
+ }
758
+
670
759
 
671
760
  try {
672
761
  scheduler.store.recordRun(taskId, runInfo);
@@ -763,6 +852,7 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
763
852
  if (nextTask) {
764
853
  logger.info(`[dsh-cron] triggering chained task "${nextTask.title}" (${targetId}) from "${task.title}" (depth ${currentDepth + 1})`);
765
854
  scheduler.runNow(targetId, {
855
+ chainRunId,
766
856
  chainDepth: currentDepth + 1,
767
857
  prevOutput: outcome.output || outcome.error || '',
768
858
  prevTaskId: task.id,
package/lib/scheduler.js CHANGED
@@ -361,6 +361,26 @@ export class TaskScheduler {
361
361
  start() {
362
362
  this.isStopped = false;
363
363
  this.generation = (this.generation || 0) + 1;
364
+
365
+ // Recovery & cleanup on restart (#317, #321)
366
+ if (this.store && this.store.durableJournal) {
367
+ try {
368
+ const recovered = this.store.durableJournal.recoverPendingRuns(this);
369
+ if (recovered.uncertain.length > 0) {
370
+ logger.warn(`[dsh-cron] Recovered ${recovered.uncertain.length} in-flight run(s) interrupted by restart -> status marked uncertain`);
371
+ }
372
+ } catch (recErr) {
373
+ logger.error('[dsh-cron] restart recovery failed:', recErr.message);
374
+ }
375
+ }
376
+ if (this.store && this.store.artifactManager) {
377
+ try {
378
+ this.store.artifactManager.cleanupArtifacts(this.store);
379
+ } catch (cleanErr) {
380
+ logger.warn('[dsh-cron] artifact cleanup failed:', cleanErr.message);
381
+ }
382
+ }
383
+
364
384
  const tasks = this.store.list();
365
385
  const now = Date.now();
366
386
  for (const task of tasks) {
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/store.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { DurableJournal } from './durable-journal.js';
2
+ import { ChainCheckpointManager } from './chain-checkpoints.js';
3
+ import { ArtifactManager } from './artifacts.js';
1
4
  import { logger } from './logger.js';
2
5
  import { bestEffort } from './best-effort.js';
3
6
  import fs from 'node:fs';
@@ -36,6 +39,12 @@ export class TaskStore {
36
39
  this.tasks = new Map();
37
40
  this.history = new Map();
38
41
  this.settings = {};
42
+ this.chainCheckpoints = {};
43
+ this.artifacts = {};
44
+ const storeDir = path.dirname(this.filePath);
45
+ this.durableJournal = new DurableJournal(this);
46
+ this.chainCheckpointManager = new ChainCheckpointManager(this);
47
+ this.artifactManager = new ArtifactManager(path.join(storeDir, "artifacts"));
39
48
  this._lastBackupTime = 0;
40
49
  this._saveTimer = null;
41
50
  this._savePromise = Promise.resolve();
@@ -85,6 +94,12 @@ export class TaskStore {
85
94
  if (data.settings && typeof data.settings === 'object') {
86
95
  this.settings = data.settings;
87
96
  }
97
+ if (data.chainCheckpoints && typeof data.chainCheckpoints === 'object') {
98
+ this.chainCheckpoints = data.chainCheckpoints;
99
+ }
100
+ if (data.artifacts && typeof data.artifacts === 'object') {
101
+ this.artifacts = data.artifacts;
102
+ }
88
103
  };
89
104
 
90
105
  if (fs.existsSync(this.filePath)) {
@@ -189,6 +204,8 @@ export class TaskStore {
189
204
  tasks: tasksSnapshot,
190
205
  history: historySnapshot,
191
206
  settings: this.settings ? { ...this.settings } : {},
207
+ chainCheckpoints: this.chainCheckpoints ? { ...this.chainCheckpoints } : {},
208
+ artifacts: this.artifacts ? { ...this.artifacts } : {},
192
209
  };
193
210
  }
194
211
 
@@ -347,7 +364,10 @@ export class TaskStore {
347
364
  yield ']';
348
365
  await new Promise((r) => setImmediate(r));
349
366
  }
350
- yield '}}';
367
+ yield '}';
368
+ yield ',"chainCheckpoints":' + JSON.stringify(data.chainCheckpoints || {});
369
+ yield ',"artifacts":' + JSON.stringify(data.artifacts || {});
370
+ yield '}';
351
371
  }
352
372
 
353
373
  try {
@@ -407,6 +427,32 @@ export class TaskStore {
407
427
  }
408
428
  }
409
429
 
430
+ getRun(runId) {
431
+ if (!runId) return null;
432
+ for (const [taskId, runs] of this.history.entries()) {
433
+ if (Array.isArray(runs)) {
434
+ const found = runs.find((r) => r.id === runId);
435
+ if (found) return { ...found, taskId };
436
+ }
437
+ }
438
+ return null;
439
+ }
440
+
441
+ recordArtifact(runIdOrRecord, maybeRecord) {
442
+ const runId = maybeRecord ? runIdOrRecord : (runIdOrRecord && runIdOrRecord.runId);
443
+ const artifactRecord = maybeRecord ? maybeRecord : runIdOrRecord;
444
+ if (!runId || !artifactRecord) return;
445
+ this.artifacts = this.artifacts || {};
446
+ this.artifacts[runId] = this.artifacts[runId] || [];
447
+ this.artifacts[runId].push(artifactRecord);
448
+ this.scheduleSave(50);
449
+ }
450
+
451
+ getArtifactsForRun(runId) {
452
+ if (!runId || !this.artifacts) return [];
453
+ return this.artifacts[runId] || [];
454
+ }
455
+
410
456
  getSettings() {
411
457
  const defaults = getDshDefaultTelegramCredentials();
412
458
  const base = { ...this.settings };
@@ -706,6 +752,9 @@ export class TaskStore {
706
752
  },
707
753
  delivery: runInfo.delivery || null,
708
754
  validation: runInfo.validation || null,
755
+ idempotencyKey: runInfo.idempotencyKey || null,
756
+ chainRunId: runInfo.chainRunId || null,
757
+ artifacts: runInfo.artifacts || null,
709
758
  sessionId: runInfo.sessionId || null,
710
759
  // #45: which model produced the run, and whether it only finished thanks
711
760
  // to the configured fallback.
@@ -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.3.0",
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",