@goodandready/dsh-cron 0.3.3 → 0.3.4

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,236 @@
1
+ /**
2
+ * Weekly AI Analytics & Cost Digest Engine (#330)
3
+ *
4
+ * Aggregates automation performance, success rates, token and USD spending,
5
+ * identifies top unstable and top expensive tasks, and formats delivery payloads.
6
+ */
7
+
8
+ export function buildAnalyticsDigest({ tasks = [], history = [], costLedger = [], periodDays = 7, now = Date.now() }) {
9
+ const windowDays = typeof periodDays === 'number' && periodDays > 0 ? periodDays : 7;
10
+ const windowStart = now - (windowDays * 86400000);
11
+
12
+ const taskList = Array.isArray(tasks) ? tasks : [];
13
+ const historyList = Array.isArray(history) ? history : [];
14
+ const costList = Array.isArray(costLedger) ? costLedger : [];
15
+
16
+ // Filter history runs in window
17
+ const runsInWindow = historyList.filter((r) => {
18
+ const t = r.startedAt || r.completedAt || r.timestamp;
19
+ return typeof t === 'number' && t >= windowStart;
20
+ });
21
+
22
+ let totalRuns = 0;
23
+ let successfulRuns = 0;
24
+ let failedRuns = 0;
25
+ let totalTokens = 0;
26
+ let totalCostUsd = 0;
27
+ let healedCount = 0;
28
+
29
+ const taskStatsMap = new Map();
30
+
31
+ for (const task of taskList) {
32
+ taskStatsMap.set(task.id, {
33
+ id: task.id,
34
+ name: task.name || task.id,
35
+ runs: 0,
36
+ failures: 0,
37
+ tokens: 0,
38
+ costUsd: 0,
39
+ });
40
+ }
41
+
42
+ if (runsInWindow.length > 0) {
43
+ totalRuns = runsInWindow.length;
44
+ for (const r of runsInWindow) {
45
+ const isFailed = r.status === 'failure' || r.status === 'failed' || Boolean(r.error);
46
+ if (isFailed) {
47
+ failedRuns += 1;
48
+ } else {
49
+ successfulRuns += 1;
50
+ }
51
+
52
+ if (r.healed || r.autoHealed || (typeof r.retryCount === 'number' && r.retryCount > 0 && !isFailed)) {
53
+ healedCount += 1;
54
+ }
55
+
56
+ const tokens = typeof r.tokens === 'number' ? r.tokens : (typeof r.tokensUsed === 'number' ? r.tokensUsed : 0);
57
+ totalTokens += tokens;
58
+
59
+ const cost = typeof r.costUsd === 'number' ? r.costUsd : 0;
60
+ totalCostUsd += cost;
61
+
62
+ if (r.taskId && taskStatsMap.has(r.taskId)) {
63
+ const ts = taskStatsMap.get(r.taskId);
64
+ ts.runs += 1;
65
+ if (isFailed) ts.failures += 1;
66
+ ts.tokens += tokens;
67
+ ts.costUsd += cost;
68
+ }
69
+ }
70
+ } else {
71
+ // Fallback: aggregate from task summary counters
72
+ for (const task of taskList) {
73
+ const runs = typeof task.runs === 'number' ? task.runs : 0;
74
+ const failures = typeof task.failures === 'number' ? task.failures : 0;
75
+ const successes = Math.max(0, runs - failures);
76
+ const tokens = typeof task.tokensUsed === 'number' ? task.tokensUsed : 0;
77
+ const cost = typeof task.costUsd === 'number' ? task.costUsd : 0;
78
+
79
+ totalRuns += runs;
80
+ successfulRuns += successes;
81
+ failedRuns += failures;
82
+ totalTokens += tokens;
83
+ totalCostUsd += cost;
84
+
85
+ const ts = taskStatsMap.get(task.id);
86
+ if (ts) {
87
+ ts.runs = runs;
88
+ ts.failures = failures;
89
+ ts.tokens = tokens;
90
+ ts.costUsd = cost;
91
+ }
92
+ }
93
+ }
94
+
95
+ // Calculate cost from ledger if available and higher
96
+ const ledgerCostInWindow = costList
97
+ .filter((c) => typeof c.timestamp === 'number' && c.timestamp >= windowStart)
98
+ .reduce((sum, c) => sum + (typeof c.costUsd === 'number' ? c.costUsd : 0), 0);
99
+ if (ledgerCostInWindow > totalCostUsd) {
100
+ totalCostUsd = ledgerCostInWindow;
101
+ }
102
+
103
+ const successRate = totalRuns > 0 ? Number(((successfulRuns / totalRuns) * 100).toFixed(1)) : 100.0;
104
+
105
+ // Identify top unstable tasks
106
+ const topUnstableTasks = Array.from(taskStatsMap.values())
107
+ .filter((ts) => ts.failures > 0)
108
+ .sort((a, b) => b.failures - a.failures || (b.runs - a.runs))
109
+ .slice(0, 3)
110
+ .map((ts) => ({
111
+ id: ts.id,
112
+ name: ts.name,
113
+ runs: ts.runs,
114
+ failures: ts.failures,
115
+ successRate: ts.runs > 0 ? Number((((ts.runs - ts.failures) / ts.runs) * 100).toFixed(1)) : 0.0,
116
+ }));
117
+
118
+ // Identify top expensive tasks
119
+ const topExpensiveTasks = Array.from(taskStatsMap.values())
120
+ .filter((ts) => ts.tokens > 0 || ts.costUsd > 0)
121
+ .sort((a, b) => b.costUsd - a.costUsd || b.tokens - a.tokens)
122
+ .slice(0, 3)
123
+ .map((ts) => ({
124
+ id: ts.id,
125
+ name: ts.name,
126
+ tokens: ts.tokens,
127
+ costUsd: Number(ts.costUsd.toFixed(4)),
128
+ }));
129
+
130
+ const activeTasksCount = taskList.filter((t) => t.status !== 'paused').length;
131
+ const pausedTasksCount = taskList.filter((t) => t.status === 'paused').length;
132
+
133
+ return {
134
+ periodDays: windowDays,
135
+ windowStart,
136
+ windowEnd: now,
137
+ totalRuns,
138
+ successfulRuns,
139
+ failedRuns,
140
+ successRate,
141
+ totalTokens,
142
+ totalCostUsd: Number(totalCostUsd.toFixed(4)),
143
+ healedCount,
144
+ activeTasksCount,
145
+ pausedTasksCount,
146
+ totalTasksCount: taskList.length,
147
+ topUnstableTasks,
148
+ topExpensiveTasks,
149
+ };
150
+ }
151
+
152
+ export function formatDigestMarkdown(digest) {
153
+ const {
154
+ periodDays,
155
+ totalRuns,
156
+ successfulRuns,
157
+ failedRuns,
158
+ successRate,
159
+ totalTokens,
160
+ totalCostUsd,
161
+ healedCount,
162
+ activeTasksCount,
163
+ pausedTasksCount,
164
+ topUnstableTasks,
165
+ topExpensiveTasks,
166
+ } = digest;
167
+
168
+ const lines = [
169
+ `📊 **DSH Cron Automation Digest (Last ${periodDays} Days)**`,
170
+ `• **Total Executions:** ${totalRuns} (✅ ${successfulRuns} succeeded, ❌ ${failedRuns} failed — **${successRate}%** SR)`,
171
+ `• **Token Consumption:** ${totalTokens.toLocaleString('en-US')} tokens (~$${totalCostUsd.toFixed(4)} USD)`,
172
+ `• **Task Fleet:** ${activeTasksCount} active, ${pausedTasksCount} paused`,
173
+ ];
174
+
175
+ if (healedCount > 0) {
176
+ lines.push(`• **Self-Healed Runs:** 🔄 ${healedCount} recovered via retry/failure-inspector`);
177
+ }
178
+
179
+ lines.push('');
180
+ lines.push('⚠️ **Top Unstable Automations:**');
181
+ if (topUnstableTasks.length === 0) {
182
+ lines.push('*(All automations healthy — 0 failures recorded!)*');
183
+ } else {
184
+ topUnstableTasks.forEach((t, i) => {
185
+ lines.push(`${i + 1}. \`${t.name}\` — **${t.successRate}%** success (${t.failures} failed of ${t.runs} runs)`);
186
+ });
187
+ }
188
+
189
+ lines.push('');
190
+ lines.push('💰 **Top Resource Consumers:**');
191
+ if (topExpensiveTasks.length === 0) {
192
+ lines.push('*(No significant token consumption recorded)*');
193
+ } else {
194
+ topExpensiveTasks.forEach((t, i) => {
195
+ lines.push(`${i + 1}. \`${t.name}\` — ${t.tokens.toLocaleString('en-US')} tokens ($${t.costUsd.toFixed(4)})`);
196
+ });
197
+ }
198
+
199
+ return lines.join('\n');
200
+ }
201
+
202
+ export function formatDigestTelegram(digest) {
203
+ // Return clean plain Markdown compatible with standard bot message formatting
204
+ return formatDigestMarkdown(digest);
205
+ }
206
+
207
+ export async function sendAnalyticsDigest({ digest, channels, deliveryConfig }) {
208
+ if (!channels || !digest) {
209
+ throw new Error('channels and digest are required to send analytics digest');
210
+ }
211
+
212
+ const message = formatDigestMarkdown(digest);
213
+ const results = { telegram: null, webhook: null };
214
+
215
+ if (deliveryConfig?.telegram && typeof channels.sendTelegram === 'function') {
216
+ try {
217
+ results.telegram = await channels.sendTelegram(message, { parseMode: 'Markdown' });
218
+ } catch (err) {
219
+ results.telegram = { error: err.message };
220
+ }
221
+ }
222
+
223
+ if (deliveryConfig?.webhookUrl && typeof channels.sendWebhook === 'function') {
224
+ try {
225
+ results.webhook = await channels.sendWebhook(deliveryConfig.webhookUrl, {
226
+ event: 'analytics.digest',
227
+ digest,
228
+ text: message,
229
+ });
230
+ } catch (err) {
231
+ results.webhook = { error: err.message };
232
+ }
233
+ }
234
+
235
+ return results;
236
+ }
package/lib/api.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { rollbackPromptVersion, sanitizePromptHistory } from './prompt-evolver.js';
1
2
  import { sanitizeAdaptiveConfig } from './adaptive.js';
2
3
  import { logStreamer } from './log-streamer.js';
3
4
  import { approvalManager } from './approval.js';
@@ -311,6 +312,20 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
311
312
  sendJson(res, 200, { ok: true, taskId: id, stats });
312
313
  return;
313
314
  }
315
+ if (action === 'prompt-history') {
316
+ const task = store.get(id);
317
+ if (!task) {
318
+ sendJson(res, 404, NOT_FOUND);
319
+ return;
320
+ }
321
+ sendJson(res, 200, {
322
+ ok: true,
323
+ taskId: id,
324
+ promptVersion: task.promptVersion || 1,
325
+ promptHistory: task.promptHistory || [],
326
+ });
327
+ return;
328
+ }
314
329
  if (action === 'history') {
315
330
  const limit = parseInt(url.searchParams.get('limit') || '20', 10);
316
331
  sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
@@ -350,6 +365,32 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
350
365
 
351
366
  /** Task actions that are plain POSTs. Returns true when one matched. */
352
367
  async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
368
+ if (action === 'prompt-rollback') {
369
+ const task = store.get(id);
370
+ if (!task) {
371
+ sendJson(res, 404, NOT_FOUND);
372
+ return true;
373
+ }
374
+ const body = await readBodyJson(req).catch(() => ({}));
375
+ const targetVersion = Number(body.version || url.searchParams.get('version'));
376
+ if (!targetVersion || targetVersion < 1) {
377
+ sendJson(res, 400, { ok: false, error: 'A valid target prompt version is required' });
378
+ return true;
379
+ }
380
+ try {
381
+ rollbackPromptVersion(task, targetVersion);
382
+ store.set(task);
383
+ sendJson(res, 200, {
384
+ ok: true,
385
+ task: redactTaskSecrets(task),
386
+ promptVersion: task.promptVersion,
387
+ promptHistory: task.promptHistory,
388
+ });
389
+ } catch (err) {
390
+ sendJson(res, 400, { ok: false, error: err.message });
391
+ }
392
+ return true;
393
+ }
353
394
  if (action === 'retry-delivery') {
354
395
  const task = store.get(id);
355
396
  if (!task) {