@goodandready/dsh-cron 0.3.4 → 0.3.6

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/routes.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { calculateCostHeatmap } from './timeline.js';
1
2
  import { buildAnalyticsDigest, formatDigestMarkdown, sendAnalyticsDigest } from './analytics-digest.js';
2
3
  import { listVerifiedRecipes } from './recipes-catalog.js';
3
4
  import { randomUUID } from 'node:crypto';
@@ -111,6 +112,26 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
111
112
  const path = url.pathname;
112
113
 
113
114
  if (req.method === 'GET') {
115
+ if (path === '/dsh-cron/analytics/heatmap') {
116
+ const modelFilter = url.searchParams.get('model') || null;
117
+ const providerFilter = url.searchParams.get('provider') || null;
118
+ const costLedger = store.costLedger || [];
119
+ let history = [];
120
+ if (store.history) {
121
+ if (typeof store.history.values === 'function') {
122
+ for (const runs of store.history.values()) {
123
+ if (Array.isArray(runs)) history.push(...runs);
124
+ }
125
+ } else if (typeof store.history === 'object') {
126
+ for (const runs of Object.values(store.history)) {
127
+ if (Array.isArray(runs)) history.push(...runs);
128
+ }
129
+ }
130
+ }
131
+ const heatmap = calculateCostHeatmap({ costLedger, history, modelFilter, providerFilter });
132
+ sendJson(res, 200, { ok: true, heatmap });
133
+ return;
134
+ }
114
135
  const days = parseInt(url.searchParams.get('days') || '7', 10);
115
136
  const tasks = store.list ? store.list() : Object.values(store.tasks || {});
116
137
  let history = [];
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Visual Schedule Timeline & Cost Heatmap Engine (#332, #333)
3
+ *
4
+ * 1. 24-hour schedule projection with collision and peak concurrency detection.
5
+ * 2. 7x24 weekly hour-of-day cost and token burn heatmap matrix.
6
+ */
7
+
8
+ import { Cron } from 'croner';
9
+
10
+ const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
11
+
12
+ export function calculate24hTimeline({
13
+ tasks = [],
14
+ maxConcurrent = 3,
15
+ fromTimestamp = Date.now(),
16
+ hoursCount = 24,
17
+ }) {
18
+ const limit = Math.max(1, Number(maxConcurrent) || 3);
19
+ const activeTasks = (Array.isArray(tasks) ? tasks : Object.values(tasks || {})).filter(
20
+ (t) => t && t.status === 'active'
21
+ );
22
+
23
+ const startMs = typeof fromTimestamp === 'number' ? fromTimestamp : Date.now();
24
+ const endMs = startMs + hoursCount * 3600000;
25
+
26
+ const buckets = Array.from({ length: hoursCount }, (_, i) => {
27
+ const bucketStart = startMs + i * 3600000;
28
+ const d = new Date(bucketStart);
29
+ const hourPad = String(d.getHours()).padStart(2, '0');
30
+ return {
31
+ index: i,
32
+ label: `${hourPad}:00`,
33
+ startMs: bucketStart,
34
+ endMs: bucketStart + 3600000,
35
+ runs: [],
36
+ count: 0,
37
+ isPeak: false,
38
+ };
39
+ });
40
+
41
+ let totalRuns = 0;
42
+
43
+ for (const task of activeTasks) {
44
+ if (task.oneShot) {
45
+ const runAt = Number(task.nextRunAt || task.targetTimestamp);
46
+ if (runAt >= startMs && runAt < endMs) {
47
+ const bIdx = Math.floor((runAt - startMs) / 3600000);
48
+ if (bIdx >= 0 && bIdx < hoursCount) {
49
+ buckets[bIdx].runs.push({
50
+ taskId: task.id,
51
+ title: task.title || task.id,
52
+ runAt,
53
+ durationEstMs: task.avgDuration || 2000,
54
+ type: task.type || 'llm',
55
+ });
56
+ totalRuns += 1;
57
+ }
58
+ }
59
+ continue;
60
+ }
61
+
62
+ if (task.schedule) {
63
+ try {
64
+ const c = new Cron(task.schedule, { timezone: task.timezone });
65
+ let cursor = new Date(startMs - 1);
66
+ // Bound to max 96 occurrences per task in 24h window
67
+ for (let step = 0; step < 96; step++) {
68
+ const nextDate = c.nextRun(cursor);
69
+ if (!nextDate) break;
70
+ const runMs = nextDate.getTime();
71
+ if (runMs >= endMs) break;
72
+
73
+ const bIdx = Math.floor((runMs - startMs) / 3600000);
74
+ if (bIdx >= 0 && bIdx < hoursCount) {
75
+ buckets[bIdx].runs.push({
76
+ taskId: task.id,
77
+ title: task.title || task.id,
78
+ runAt: runMs,
79
+ durationEstMs: task.avgDuration || 2000,
80
+ type: task.type || 'llm',
81
+ });
82
+ totalRuns += 1;
83
+ }
84
+ cursor = new Date(runMs + 1000);
85
+ }
86
+ } catch (err) {
87
+ // Fallback for custom or invalid cron schedules
88
+ const runAt = Number(task.nextRunAt);
89
+ if (runAt >= startMs && runAt < endMs) {
90
+ const bIdx = Math.floor((runAt - startMs) / 3600000);
91
+ if (bIdx >= 0 && bIdx < hoursCount) {
92
+ buckets[bIdx].runs.push({
93
+ taskId: task.id,
94
+ title: task.title || task.id,
95
+ runAt,
96
+ durationEstMs: task.avgDuration || 2000,
97
+ type: task.type || 'llm',
98
+ });
99
+ totalRuns += 1;
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ const peakHours = [];
107
+
108
+ for (const b of buckets) {
109
+ b.count = b.runs.length;
110
+ b.isPeak = b.count > limit;
111
+ if (b.isPeak) {
112
+ peakHours.push(b.label);
113
+ }
114
+ }
115
+
116
+ return {
117
+ fromTimestamp: startMs,
118
+ toTimestamp: endMs,
119
+ maxConcurrent: limit,
120
+ totalRuns,
121
+ peakHoursCount: peakHours.length,
122
+ peakHours,
123
+ buckets,
124
+ };
125
+ }
126
+
127
+ export function calculateCostHeatmap({
128
+ costLedger = [],
129
+ history = [],
130
+ modelFilter = null,
131
+ providerFilter = null,
132
+ }) {
133
+ const ledger = Array.isArray(costLedger) ? costLedger : [];
134
+ const historyList = Array.isArray(history) ? history : [];
135
+
136
+ // Matrix: 7 rows (Mon..Sun) x 24 columns (00..23h)
137
+ const matrix = Array.from({ length: 7 }, (_, dIdx) =>
138
+ Array.from({ length: 24 }, (_, hIdx) => ({
139
+ day: dIdx,
140
+ dayLabel: DAY_LABELS[dIdx],
141
+ hour: hIdx,
142
+ hourLabel: `${String(hIdx).padStart(2, '0')}:00`,
143
+ tokens: 0,
144
+ costUsd: 0,
145
+ runs: 0,
146
+ costIntensity: 0,
147
+ tokenIntensity: 0,
148
+ }))
149
+ );
150
+
151
+ let totalCostUsd = 0;
152
+ let totalTokens = 0;
153
+ let totalRuns = 0;
154
+
155
+ // Aggregate costLedger
156
+ for (const entry of ledger) {
157
+ if (!entry || typeof entry.timestamp !== 'number') continue;
158
+ if (modelFilter && entry.model && entry.model !== modelFilter) continue;
159
+ if (providerFilter && entry.provider && entry.provider !== providerFilter) continue;
160
+
161
+ const d = new Date(entry.timestamp);
162
+ const dayOfWeek = (d.getUTCDay() + 6) % 7; // Convert Sun(0) to 6, Mon(1) to 0
163
+ const hour = d.getUTCHours();
164
+
165
+ if (dayOfWeek >= 0 && dayOfWeek < 7 && hour >= 0 && hour < 24) {
166
+ const cell = matrix[dayOfWeek][hour];
167
+ const cost = typeof entry.costUsd === 'number' ? entry.costUsd : 0;
168
+ const tokens = typeof entry.tokens === 'number' ? entry.tokens : 0;
169
+
170
+ cell.costUsd += cost;
171
+ cell.tokens += tokens;
172
+ totalCostUsd += cost;
173
+ totalTokens += tokens;
174
+ }
175
+ }
176
+
177
+ // Aggregate run counts from history
178
+ for (const r of historyList) {
179
+ if (!r) continue;
180
+ const t = r.startedAt || r.completedAt || r.timestamp;
181
+ if (typeof t !== 'number') continue;
182
+ if (modelFilter && r.model && r.model !== modelFilter) continue;
183
+ if (providerFilter && r.provider && r.provider !== providerFilter) continue;
184
+
185
+ const d = new Date(t);
186
+ const dayOfWeek = (d.getUTCDay() + 6) % 7;
187
+ const hour = d.getUTCHours();
188
+
189
+ if (dayOfWeek >= 0 && dayOfWeek < 7 && hour >= 0 && hour < 24) {
190
+ matrix[dayOfWeek][hour].runs += 1;
191
+ totalRuns += 1;
192
+ }
193
+ }
194
+
195
+ // Calculate maximums for intensity normalisation
196
+ let maxCostHour = 0;
197
+ let maxTokensHour = 0;
198
+
199
+ for (let d = 0; d < 7; d++) {
200
+ for (let h = 0; h < 24; h++) {
201
+ const cell = matrix[d][h];
202
+ cell.costUsd = Number(cell.costUsd.toFixed(4));
203
+ if (cell.costUsd > maxCostHour) maxCostHour = cell.costUsd;
204
+ if (cell.tokens > maxTokensHour) maxTokensHour = cell.tokens;
205
+ }
206
+ }
207
+
208
+ // Normalise intensity (0.0 to 1.0)
209
+ const costDenom = maxCostHour > 0 ? maxCostHour : 1;
210
+ const tokenDenom = maxTokensHour > 0 ? maxTokensHour : 1;
211
+
212
+ for (let d = 0; d < 7; d++) {
213
+ for (let h = 0; h < 24; h++) {
214
+ const cell = matrix[d][h];
215
+ cell.costIntensity = Number((cell.costUsd / costDenom).toFixed(3));
216
+ cell.tokenIntensity = Number((cell.tokens / tokenDenom).toFixed(3));
217
+ }
218
+ }
219
+
220
+ return {
221
+ dayLabels: DAY_LABELS,
222
+ hourLabels: Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`),
223
+ matrix,
224
+ maxCostHour,
225
+ maxTokensHour,
226
+ totalCostUsd: Number(totalCostUsd.toFixed(4)),
227
+ totalTokens,
228
+ totalRuns,
229
+ };
230
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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",