@goodandready/dsh-cron 0.3.3 → 0.3.5
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/analytics-digest.js +236 -0
- package/lib/api.js +46 -0
- package/lib/client.js +4925 -4515
- package/lib/prompt-evolver.js +148 -0
- package/lib/routes.js +100 -0
- package/lib/scheduler-execution.js +10 -0
- package/lib/task-create.js +3 -0
- package/lib/task-patch.js +9 -0
- package/lib/task-transfer.js +2 -0
- package/lib/timeline.js +230 -0
- package/package.json +1 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt Auto-Evolver & Versioning Engine (#331)
|
|
3
|
+
*
|
|
4
|
+
* Tracks prompt revisions, maintains versioned prompt history, computes
|
|
5
|
+
* empirical Success Rates across versions, and enables 1-click rollback.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MAX_PROMPT_HISTORY = 20;
|
|
9
|
+
|
|
10
|
+
export function sanitizePromptHistory(history) {
|
|
11
|
+
if (!Array.isArray(history)) return [];
|
|
12
|
+
return history
|
|
13
|
+
.filter((h) => h && typeof h === 'object' && typeof h.prompt === 'string')
|
|
14
|
+
.slice(-MAX_PROMPT_HISTORY)
|
|
15
|
+
.map((h, idx) => ({
|
|
16
|
+
version: typeof h.version === 'number' && h.version > 0 ? h.version : (idx + 1),
|
|
17
|
+
prompt: String(h.prompt || ''),
|
|
18
|
+
note: typeof h.note === 'string' ? h.note.slice(0, 100) : 'Revision',
|
|
19
|
+
runs: typeof h.runs === 'number' && h.runs >= 0 ? h.runs : 0,
|
|
20
|
+
successes: typeof h.successes === 'number' && h.successes >= 0 ? h.successes : 0,
|
|
21
|
+
successRate: typeof h.successRate === 'number' && h.successRate >= 0 && h.successRate <= 1
|
|
22
|
+
? Number(h.successRate.toFixed(3))
|
|
23
|
+
: (typeof h.runs === 'number' && h.runs > 0 ? Number(((h.successes || 0) / h.runs).toFixed(3)) : 1.0),
|
|
24
|
+
createdAt: typeof h.createdAt === 'number' ? h.createdAt : Date.now(),
|
|
25
|
+
lastRunAt: typeof h.lastRunAt === 'number' ? h.lastRunAt : null,
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function trackPromptEdit(currentTask, newPrompt, note = 'Manual edit') {
|
|
30
|
+
if (!currentTask || typeof newPrompt !== 'string') {
|
|
31
|
+
return {
|
|
32
|
+
prompt: currentTask ? currentTask.prompt : newPrompt,
|
|
33
|
+
promptVersion: currentTask ? (currentTask.promptVersion || 1) : 1,
|
|
34
|
+
promptHistory: currentTask ? sanitizePromptHistory(currentTask.promptHistory) : [],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const trimmedNew = newPrompt.trim();
|
|
39
|
+
const trimmedOld = (currentTask.prompt || '').trim();
|
|
40
|
+
|
|
41
|
+
let history = sanitizePromptHistory(currentTask.promptHistory);
|
|
42
|
+
|
|
43
|
+
// If prompt has not changed, keep current version and baseline history if needed
|
|
44
|
+
if (trimmedNew === trimmedOld) {
|
|
45
|
+
if (history.length === 0 && trimmedOld) {
|
|
46
|
+
history.push({
|
|
47
|
+
version: currentTask.promptVersion || 1,
|
|
48
|
+
prompt: currentTask.prompt,
|
|
49
|
+
note: 'Initial baseline',
|
|
50
|
+
runs: typeof currentTask.runs === 'number' ? currentTask.runs : 0,
|
|
51
|
+
successes: typeof currentTask.successes === 'number' ? currentTask.successes : 0,
|
|
52
|
+
successRate: currentTask.runs > 0
|
|
53
|
+
? Number(((currentTask.successes || 0) / currentTask.runs).toFixed(3))
|
|
54
|
+
: 1.0,
|
|
55
|
+
createdAt: currentTask.createdAt || Date.now(),
|
|
56
|
+
lastRunAt: currentTask.lastRun || null,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
prompt: currentTask.prompt,
|
|
61
|
+
promptVersion: currentTask.promptVersion || 1,
|
|
62
|
+
promptHistory: history,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// If no history exists yet, record baseline version 1 with existing task metrics before bumping
|
|
67
|
+
if (history.length === 0 && trimmedOld) {
|
|
68
|
+
history.push({
|
|
69
|
+
version: 1,
|
|
70
|
+
prompt: currentTask.prompt,
|
|
71
|
+
note: 'Initial baseline',
|
|
72
|
+
runs: typeof currentTask.runs === 'number' ? currentTask.runs : 0,
|
|
73
|
+
successes: typeof currentTask.successes === 'number' ? currentTask.successes : 0,
|
|
74
|
+
successRate: currentTask.runs > 0
|
|
75
|
+
? Number(((currentTask.successes || 0) / currentTask.runs).toFixed(3))
|
|
76
|
+
: 1.0,
|
|
77
|
+
createdAt: currentTask.createdAt || Date.now(),
|
|
78
|
+
lastRunAt: currentTask.lastRun || null,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const maxVersion = history.reduce((max, h) => Math.max(max, h.version || 1), currentTask.promptVersion || 1);
|
|
83
|
+
const nextVersion = maxVersion + 1;
|
|
84
|
+
|
|
85
|
+
history.push({
|
|
86
|
+
version: nextVersion,
|
|
87
|
+
prompt: newPrompt,
|
|
88
|
+
note: String(note || 'Manual edit').slice(0, 100),
|
|
89
|
+
runs: 0,
|
|
90
|
+
successes: 0,
|
|
91
|
+
successRate: 1.0,
|
|
92
|
+
createdAt: Date.now(),
|
|
93
|
+
lastRunAt: null,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
prompt: newPrompt,
|
|
98
|
+
promptVersion: nextVersion,
|
|
99
|
+
promptHistory: sanitizePromptHistory(history),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function recordPromptRunOutcome(task, isSuccess) {
|
|
104
|
+
if (!task || typeof task.prompt !== 'string') return task?.promptHistory || [];
|
|
105
|
+
|
|
106
|
+
let history = sanitizePromptHistory(task.promptHistory);
|
|
107
|
+
const activeVersion = task.promptVersion || 1;
|
|
108
|
+
|
|
109
|
+
let entry = history.find((h) => h.version === activeVersion);
|
|
110
|
+
if (!entry) {
|
|
111
|
+
entry = {
|
|
112
|
+
version: activeVersion,
|
|
113
|
+
prompt: task.prompt,
|
|
114
|
+
note: 'Active prompt',
|
|
115
|
+
runs: 0,
|
|
116
|
+
successes: 0,
|
|
117
|
+
successRate: 1.0,
|
|
118
|
+
createdAt: Date.now(),
|
|
119
|
+
lastRunAt: null,
|
|
120
|
+
};
|
|
121
|
+
history.push(entry);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
entry.runs = (entry.runs || 0) + 1;
|
|
125
|
+
if (isSuccess) {
|
|
126
|
+
entry.successes = (entry.successes || 0) + 1;
|
|
127
|
+
}
|
|
128
|
+
entry.successRate = Number((entry.successes / entry.runs).toFixed(3));
|
|
129
|
+
entry.lastRunAt = Date.now();
|
|
130
|
+
|
|
131
|
+
task.promptHistory = sanitizePromptHistory(history);
|
|
132
|
+
return task.promptHistory;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function rollbackPromptVersion(task, targetVersion) {
|
|
136
|
+
if (!task) return null;
|
|
137
|
+
const history = sanitizePromptHistory(task.promptHistory);
|
|
138
|
+
const target = history.find((h) => h.version === targetVersion);
|
|
139
|
+
if (!target) {
|
|
140
|
+
throw new Error(`Target prompt version ${targetVersion} not found in history`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const edit = trackPromptEdit(task, target.prompt, `Rollback to v${targetVersion}`);
|
|
144
|
+
task.prompt = edit.prompt;
|
|
145
|
+
task.promptVersion = edit.promptVersion;
|
|
146
|
+
task.promptHistory = edit.promptHistory;
|
|
147
|
+
return task;
|
|
148
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { calculateCostHeatmap } from './timeline.js';
|
|
2
|
+
import { buildAnalyticsDigest, formatDigestMarkdown, sendAnalyticsDigest } from './analytics-digest.js';
|
|
1
3
|
import { listVerifiedRecipes } from './recipes-catalog.js';
|
|
2
4
|
import { randomUUID } from 'node:crypto';
|
|
3
5
|
import { createCronApiHandler } from './api.js';
|
|
@@ -100,6 +102,104 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
|
100
102
|
}
|
|
101
103
|
}), 'dsh-cron: /chat/start');
|
|
102
104
|
|
|
105
|
+
// Analytics digest routes (#330)
|
|
106
|
+
ctx.effect(() => ctx.webServer.register({
|
|
107
|
+
kind: 'prefix',
|
|
108
|
+
path: '/dsh-cron/analytics',
|
|
109
|
+
handler: async (req, res) => {
|
|
110
|
+
try {
|
|
111
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
112
|
+
const path = url.pathname;
|
|
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
|
+
}
|
|
135
|
+
const days = parseInt(url.searchParams.get('days') || '7', 10);
|
|
136
|
+
const tasks = store.list ? store.list() : Object.values(store.tasks || {});
|
|
137
|
+
let history = [];
|
|
138
|
+
if (store.history) {
|
|
139
|
+
if (typeof store.history.values === 'function') {
|
|
140
|
+
for (const runs of store.history.values()) {
|
|
141
|
+
if (Array.isArray(runs)) history.push(...runs);
|
|
142
|
+
}
|
|
143
|
+
} else if (typeof store.history === 'object') {
|
|
144
|
+
for (const runs of Object.values(store.history)) {
|
|
145
|
+
if (Array.isArray(runs)) history.push(...runs);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const costLedger = store.costLedger || [];
|
|
150
|
+
const digest = buildAnalyticsDigest({ tasks, history, costLedger, periodDays: days });
|
|
151
|
+
const markdown = formatDigestMarkdown(digest);
|
|
152
|
+
sendJson(res, 200, { ok: true, digest, markdown });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (req.method === 'POST') {
|
|
157
|
+
if (rejectCrossOrigin(req, res)) return;
|
|
158
|
+
const { body } = await readBody(req, res);
|
|
159
|
+
const days = parseInt(body?.days || url.searchParams.get('days') || '7', 10);
|
|
160
|
+
const tasks = store.list ? store.list() : Object.values(store.tasks || {});
|
|
161
|
+
let history = [];
|
|
162
|
+
if (store.history) {
|
|
163
|
+
if (typeof store.history.values === 'function') {
|
|
164
|
+
for (const runs of store.history.values()) {
|
|
165
|
+
if (Array.isArray(runs)) history.push(...runs);
|
|
166
|
+
}
|
|
167
|
+
} else if (typeof store.history === 'object') {
|
|
168
|
+
for (const runs of Object.values(store.history)) {
|
|
169
|
+
if (Array.isArray(runs)) history.push(...runs);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const costLedger = store.costLedger || [];
|
|
174
|
+
const digest = buildAnalyticsDigest({ tasks, history, costLedger, periodDays: days });
|
|
175
|
+
const settings = store.getSettings ? store.getSettings() : {};
|
|
176
|
+
const deliveryConfig = {
|
|
177
|
+
telegram: body?.telegram !== false && Boolean(settings.botToken && settings.chatId),
|
|
178
|
+
webhookUrl: body?.webhookUrl || settings.webhookUrl,
|
|
179
|
+
};
|
|
180
|
+
const channels = {
|
|
181
|
+
sendTelegram: async (text) => sendTelegramMessage({ botToken: settings.botToken, chatId: settings.chatId, text }),
|
|
182
|
+
sendWebhook: async (targetUrl, payload) => {
|
|
183
|
+
const r = await fetch(targetUrl, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'Content-Type': 'application/json' },
|
|
186
|
+
body: JSON.stringify(payload),
|
|
187
|
+
});
|
|
188
|
+
return { ok: r.ok, status: r.status };
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
const delivery = await sendAnalyticsDigest({ digest, channels, deliveryConfig });
|
|
192
|
+
sendJson(res, 200, { ok: true, digest, delivery });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
sendJson(res, 405, { ok: false, error: 'Method not allowed' });
|
|
197
|
+
} catch (err) {
|
|
198
|
+
sendJson(res, 500, { ok: false, error: err.message });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}), 'dsh-cron: /analytics');
|
|
202
|
+
|
|
103
203
|
// GET /dsh-cron/recipes (#48): the built-in recipe catalog.
|
|
104
204
|
ctx.effect(() => ctx.webServer.register({
|
|
105
205
|
kind: 'exact',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recordPromptRunOutcome } from './prompt-evolver.js';
|
|
1
2
|
import { evaluateAdaptiveRun, calculateNextAdaptiveState } from './adaptive.js';
|
|
2
3
|
import { approvalManager } from './approval.js';
|
|
3
4
|
import { globalBudgetManager } from './global-budgets.js';
|
|
@@ -513,6 +514,15 @@ export async function deliverNotifications(task, runInfo, { store, resolveSecret
|
|
|
513
514
|
export function finishRun(scheduler, taskSnapshot, taskId, status, queuedCount, meta = {}) {
|
|
514
515
|
scheduler.countRun(status);
|
|
515
516
|
|
|
517
|
+
// Track prompt version success rate outcome (#331)
|
|
518
|
+
if (taskSnapshot && taskSnapshot.prompt && (taskSnapshot.promptVersion || taskSnapshot.promptHistory)) {
|
|
519
|
+
const isSuccess = status === 'success';
|
|
520
|
+
recordPromptRunOutcome(taskSnapshot, isSuccess);
|
|
521
|
+
if (scheduler.store && typeof scheduler.store.set === 'function') {
|
|
522
|
+
scheduler.store.set(taskSnapshot);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
516
526
|
// Adaptive polling backoff (#329)
|
|
517
527
|
if (taskSnapshot && taskSnapshot.adaptive && taskSnapshot.adaptive.enabled && !taskSnapshot.oneShot && taskSnapshot.status === 'active' && !scheduler.isStopped) {
|
|
518
528
|
const history = scheduler.store.getHistory ? scheduler.store.getHistory(taskId, 2) : [];
|
package/lib/task-create.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizePromptHistory } from './prompt-evolver.js';
|
|
1
2
|
import { sanitizeAdaptiveConfig } from './adaptive.js';
|
|
2
3
|
import { sanitizeValidators } from './validators.js';
|
|
3
4
|
import { normalizeTaskType, EXTERNAL_RUNTIMES } from './runtimes.js';
|
|
@@ -33,6 +34,8 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
33
34
|
schedule: parsed.cronPattern || args.schedule,
|
|
34
35
|
scheduleText: parsed.humanText,
|
|
35
36
|
prompt: args.prompt,
|
|
37
|
+
promptVersion: args.prompt ? 1 : undefined,
|
|
38
|
+
promptHistory: args.prompt ? [{ version: 1, prompt: args.prompt, note: 'Initial creation', runs: 0, successes: 0, successRate: 1.0, createdAt: Date.now(), lastRunAt: null }] : undefined,
|
|
36
39
|
type: taskType,
|
|
37
40
|
delivery: args.delivery || 'isolated',
|
|
38
41
|
status: 'active',
|
package/lib/task-patch.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { trackPromptEdit, sanitizePromptHistory } from './prompt-evolver.js';
|
|
1
2
|
import { sanitizeAdaptiveConfig } from './adaptive.js';
|
|
2
3
|
import { sanitizeValidators } from './validators.js';
|
|
3
4
|
import { sanitizeFileWatch } from './task-create.js';
|
|
@@ -108,6 +109,14 @@ export function buildTaskPatch(current, body) {
|
|
|
108
109
|
if (patch.validators !== undefined) {
|
|
109
110
|
patch.validators = Array.isArray(patch.validators) ? sanitizeValidators(patch.validators) : [];
|
|
110
111
|
}
|
|
112
|
+
if (patch.prompt !== undefined && current && typeof current.prompt === 'string') {
|
|
113
|
+
const edit = trackPromptEdit(current, patch.prompt, body && body.promptNote ? body.promptNote : 'Manual edit');
|
|
114
|
+
patch.prompt = edit.prompt;
|
|
115
|
+
patch.promptVersion = edit.promptVersion;
|
|
116
|
+
patch.promptHistory = edit.promptHistory;
|
|
117
|
+
} else if (patch.promptHistory !== undefined) {
|
|
118
|
+
patch.promptHistory = sanitizePromptHistory(patch.promptHistory);
|
|
119
|
+
}
|
|
111
120
|
if (patch.costLimitUsd !== undefined) {
|
|
112
121
|
patch.costLimitUsd = patch.costLimitUsd === null ? null : (Number(patch.costLimitUsd) > 0 ? Number(patch.costLimitUsd) : null);
|
|
113
122
|
}
|
package/lib/task-transfer.js
CHANGED
package/lib/timeline.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.3.5",
|
|
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",
|