@goodandready/dsh-cron 0.2.5 → 0.2.7

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/llm-ask.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Small helper for asking the configured model a short question (#44, #43).
3
+ *
4
+ * The harness exposes the model through `ctx.llm.stream(...)`, which yields
5
+ * text deltas and a finish chunk. That is enough for a cheap side question such
6
+ * as "is this output worth alerting about?".
7
+ *
8
+ * Everything here fails open: any problem returns `{ ok: false }` and the caller
9
+ * falls back to its normal behaviour, so a broken model call can never silence a
10
+ * report or lose a diagnosis.
11
+ */
12
+
13
+ const DEFAULT_TIMEOUT_MS = 20000;
14
+ const DEFAULT_MAX_TOKENS = 400;
15
+
16
+ function getLlm(ctx) {
17
+ if (!ctx) return null;
18
+ if (ctx.llm && typeof ctx.llm.stream === 'function') return ctx.llm;
19
+ if (typeof ctx.get === 'function') {
20
+ const service = ctx.get('llm');
21
+ if (service && typeof service.stream === 'function') return service;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ /** Provider and model for an auxiliary question: task first, then the default. */
27
+ export function resolveAskTarget(ctx, task = {}, preferredModel = '') {
28
+ let defaultSelection = null;
29
+ try {
30
+ defaultSelection = ctx && typeof ctx.get === 'function'
31
+ ? ctx.get('agentDefaultModel')?.currentSelection?.() || null
32
+ : null;
33
+ } catch {}
34
+ return {
35
+ provider: task.provider || defaultSelection?.provider || '',
36
+ model: preferredModel || task.model || defaultSelection?.model || '',
37
+ };
38
+ }
39
+
40
+ /** Collect the assistant text and how the stream ended. */
41
+ async function collectText(stream) {
42
+ let text = '';
43
+ let finishKind = '';
44
+ for await (const chunk of stream) {
45
+ if (!chunk) continue;
46
+ if (chunk.type === 'text-delta' && chunk.text) text += chunk.text;
47
+ if (chunk.type === 'finish') {
48
+ finishKind = (chunk.reason && chunk.reason.kind) || '';
49
+ break;
50
+ }
51
+ }
52
+ return { text, finishKind };
53
+ }
54
+
55
+ /** A stream that stopped for any other reason is not a usable answer. */
56
+ function isCleanFinish(kind) {
57
+ return kind === 'stop';
58
+ }
59
+
60
+ async function buildUserMessage(text) {
61
+ const content = [{ type: 'text', text }];
62
+ const source = { kind: 'plugin', plugin: 'dsh-cron', form: 'helper-call' };
63
+ try {
64
+ const mod = await import('@deepseek-ai/dsh-llm');
65
+ if (mod && typeof mod.createUserMessage === 'function') {
66
+ return mod.createUserMessage({ content, source });
67
+ }
68
+ } catch {}
69
+ return { role: 'user', content, source };
70
+ }
71
+
72
+ /**
73
+ * Ask the model one question and return its text.
74
+ * @returns {Promise<{ok: true, text: string} | {ok: false, error: string}>}
75
+ */
76
+ export async function askModel(ctx, options = {}) {
77
+ const { provider, model, prompt, system, maxTokens, timeoutMs, purpose } = options;
78
+ const llm = getLlm(ctx);
79
+ if (!llm) return { ok: false, error: 'the harness exposes no model stream' };
80
+ if (!provider || !model) return { ok: false, error: 'no provider/model is configured for this call' };
81
+ if (!prompt) return { ok: false, error: 'no prompt given' };
82
+
83
+ const limit = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_TIMEOUT_MS;
84
+ const controller = new AbortController();
85
+ let timer;
86
+ // The abort signal only helps a stream that honours it, so the whole call is
87
+ // raced against the same deadline: a model helper must never hold a run open.
88
+ const deadline = new Promise((_, reject) => {
89
+ timer = setTimeout(() => {
90
+ controller.abort();
91
+ reject(new Error(`the model call did not finish within ${limit} ms`));
92
+ }, limit);
93
+ });
94
+
95
+ try {
96
+ const work = (async () => {
97
+ const messages = [await buildUserMessage(prompt)];
98
+ const stream = llm.stream({
99
+ provider,
100
+ model,
101
+ messages,
102
+ ...(system ? { system } : {}),
103
+ maxTokens: Number(maxTokens) > 0 ? Number(maxTokens) : DEFAULT_MAX_TOKENS,
104
+ signal: controller.signal,
105
+ ...(purpose ? { purpose } : {}),
106
+ });
107
+ const { text, finishKind } = await collectText(stream);
108
+ return { text: String(text || '').trim(), finishKind };
109
+ })();
110
+ // The abandoned stream must not surface as an unhandled rejection.
111
+ work.catch(() => {});
112
+
113
+ const { text, finishKind } = await Promise.race([work, deadline]);
114
+ if (!isCleanFinish(finishKind)) {
115
+ return { ok: false, error: `the model call ended with ${finishKind || 'no finish reason'}` };
116
+ }
117
+ return { ok: true, text };
118
+ } catch (err) {
119
+ return { ok: false, error: (err && err.message) || String(err) };
120
+ } finally {
121
+ clearTimeout(timer);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Read the first JSON object out of a model answer.
127
+ * Models like to wrap JSON in prose or fences, so the text is searched rather
128
+ * than parsed directly. Returns null when nothing usable is found.
129
+ */
130
+ export function parseJsonAnswer(text) {
131
+ const raw = String(text || '');
132
+ const start = raw.indexOf('{');
133
+ const end = raw.lastIndexOf('}');
134
+ if (start === -1 || end <= start) return null;
135
+ try {
136
+ const parsed = JSON.parse(raw.slice(start, end + 1));
137
+ return parsed && typeof parsed === 'object' ? parsed : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
package/lib/metrics.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Prometheus exposition of the scheduler (#53).
3
+ *
4
+ * The renderer is hand-rolled on purpose: the profile has no prom-client and
5
+ * adding a production dependency for a handful of counters is not justified.
6
+ * Only counts, statuses and durations are exported — never prompts, run output
7
+ * or task configuration, so the endpoint can be scraped without leaking work
8
+ * content.
9
+ */
10
+
11
+ export const METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
12
+
13
+ /** Run statuses the scheduler can finish with. */
14
+ export const RUN_STATUSES = ['success', 'error', 'timeout', 'skipped', 'missed'];
15
+
16
+ const NL = String.fromCharCode(10);
17
+ const BACKSLASH = String.fromCharCode(92);
18
+ const QUOTE = String.fromCharCode(34);
19
+
20
+ /** Escape a label value for the Prometheus text format. */
21
+ export function escapeLabel(value) {
22
+ return String(value)
23
+ .split(BACKSLASH).join(BACKSLASH + BACKSLASH)
24
+ .split(QUOTE).join(BACKSLASH + QUOTE)
25
+ .split(NL).join(BACKSLASH + 'n');
26
+ }
27
+
28
+ function sample(name, labels, value) {
29
+ const rendered = Object.keys(labels)
30
+ .map((key) => key + '=' + QUOTE + escapeLabel(labels[key]) + QUOTE)
31
+ .join(',');
32
+ return name + (rendered ? '{' + rendered + '}' : '') + ' ' + value;
33
+ }
34
+
35
+ /**
36
+ * Render the current scheduler state as Prometheus text exposition.
37
+ * Pure function over plain data so it is testable without a store.
38
+ */
39
+ export function renderMetrics({ tasks = [], stats = {}, runCounters = {} } = {}) {
40
+ const lines = [];
41
+ const byStatus = {};
42
+ for (const task of tasks) {
43
+ const status = task.status || 'unknown';
44
+ byStatus[status] = (byStatus[status] || 0) + 1;
45
+ }
46
+
47
+ lines.push('# HELP dsh_cron_tasks_total Scheduled tasks by status.');
48
+ // A gauge despite the name: this is a snapshot of how many tasks are in each
49
+ // state, not a monotonically growing total.
50
+ lines.push('# TYPE dsh_cron_tasks_total gauge');
51
+ for (const status of Object.keys(byStatus).sort()) {
52
+ lines.push(sample('dsh_cron_tasks_total', { status }, byStatus[status]));
53
+ }
54
+
55
+ lines.push('# HELP dsh_cron_task_last_duration_seconds Duration of the last finished run of a task.');
56
+ lines.push('# TYPE dsh_cron_task_last_duration_seconds gauge');
57
+ for (const task of tasks) {
58
+ // lastRunAt marks a finished run: a task that never ran still carries
59
+ // lastDurationMs = 0, and exporting that would show instant runs in
60
+ // monitoring.
61
+ if (!Number.isFinite(task.lastDurationMs) || !task.lastRunAt) continue;
62
+ lines.push(sample('dsh_cron_task_last_duration_seconds', { task: task.id }, task.lastDurationMs / 1000));
63
+ }
64
+
65
+ lines.push('# HELP dsh_cron_runs_total Finished runs since the plugin started, by status.');
66
+ lines.push('# TYPE dsh_cron_runs_total counter');
67
+ for (const status of RUN_STATUSES) {
68
+ lines.push(sample('dsh_cron_runs_total', { status }, Number(runCounters[status]) || 0));
69
+ }
70
+
71
+ lines.push('# HELP dsh_cron_run_records Run records currently kept in memory.');
72
+ lines.push('# TYPE dsh_cron_run_records gauge');
73
+ lines.push('dsh_cron_run_records ' + (Number(stats.totalRuns) || 0));
74
+
75
+ return lines.join(NL) + NL;
76
+ }
77
+
78
+ /**
79
+ * GET /dsh-cron/metrics — read-only. A scraper is a plain local client, so no
80
+ * cross-origin check applies, and nothing sensitive is exposed.
81
+ */
82
+ export function createMetricsHandler({ store, scheduler }) {
83
+ return function handleMetrics(req, res) {
84
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
85
+ res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
86
+ res.end('Method not allowed' + NL);
87
+ return;
88
+ }
89
+ const body = renderMetrics({
90
+ tasks: store.list({ status: 'all' }),
91
+ stats: store.getAggregatedStats(),
92
+ runCounters: typeof scheduler.getRunCounters === 'function' ? scheduler.getRunCounters() : {},
93
+ });
94
+ res.writeHead(200, { 'Content-Type': METRICS_CONTENT_TYPE, 'Cache-Control': 'no-store' });
95
+ res.end(req.method === 'HEAD' ? undefined : body);
96
+ };
97
+ }
package/lib/recipes.js ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Built-in recipe hub (#48): preconfigured monitoring jobs a user can create in
3
+ * one click instead of hand-writing shell commands.
4
+ *
5
+ * Two rules shape every entry:
6
+ * - read-only. No recipe deletes, prunes, restarts or installs anything; the
7
+ * worst a bad match can do is print output. Destructive maintenance stays a
8
+ * deliberate, hand-written task.
9
+ * - no secrets and no private endpoints. Everything here is a public command
10
+ * or a self-contained check.
11
+ *
12
+ * Recipes are ordinary task presets: the UI opens the normal form with the
13
+ * fields prefilled, so nothing is created without an explicit action.
14
+ */
15
+
16
+ export const RECIPE_CATEGORIES = [
17
+ { id: 'system', title: 'System health' },
18
+ { id: 'services', title: 'Services and containers' },
19
+ { id: 'storage', title: 'Storage' },
20
+ { id: 'security', title: 'Security' },
21
+ { id: 'maintenance', title: 'Maintenance windows' },
22
+ ];
23
+
24
+ /** Commands a recipe must never contain, checked by the test suite. */
25
+ /**
26
+ * Commands a recipe must never contain, checked by the test suite.
27
+ *
28
+ * Deliberately broad: this is a gate for the curated catalog, not a sandbox, so
29
+ * a false positive costs one recipe while a false negative ships a recipe that
30
+ * destroys something. An independent review of this block defeated the first
31
+ * version with `find -delete`, `shred`, `mv` and `apt purge`, so the list now
32
+ * covers deletion and wiping, raw device writes, service and container state,
33
+ * packages, users and permissions, scheduling and the network edge, power state
34
+ * and writes outside the working tree.
35
+ */
36
+ export const DESTRUCTIVE_PATTERNS = [
37
+ /\brm\b/i,
38
+ /\bunlink\b/i,
39
+ /\bshred\b/i,
40
+ /\bwipefs\b/i,
41
+ /\bmkfs\b/i,
42
+ /\btruncate\b/i,
43
+ /\bfind\b[^|;]*\s-delete\b/i,
44
+ /\bmv\s+[^|;]*\/etc\//i,
45
+ /\bdd\b/i,
46
+ /\bof=\/dev\//i,
47
+ /\bsystemctl\s+(stop|restart|disable|mask|kill)\b/i,
48
+ /\bkill(all)?\b/i,
49
+ /\bdocker\s+(?:[a-z-]+\s+)*(rm|rmi|prune|stop|kill|down)\b/i,
50
+ /\bdocker\s+volume\s+rm\b/i,
51
+ /\bapt(-get)?\s+(install|remove|purge|autoremove|upgrade|dist-upgrade|full-upgrade)\b/i,
52
+ /\bdpkg\s+(-r|-P|--purge)\b/i,
53
+ /\byum\s+(remove|erase|update|install)\b/i,
54
+ /\bpip3?\s+uninstall\b/i,
55
+ /\bnpm\s+(uninstall|rm)\b/i,
56
+ /\buserdel\b/i,
57
+ /\bgroupdel\b/i,
58
+ /\bchmod\s+(-R\s+)?0?777\b/i,
59
+ /\bchown\s+-R\b/i,
60
+ /\bcrontab\s+-r\b/i,
61
+ /\biptables\b/i,
62
+ /\bnft\s+(flush|delete)\b/i,
63
+ /\bufw\s+disable\b/i,
64
+ /\bshutdown\b/i,
65
+ /\breboot\b/i,
66
+ /\bhalt\b/i,
67
+ /\bpoweroff\b/i,
68
+ />\s*\/(etc|var|usr|boot)\//i,
69
+ /\btee\s+\/(etc|var|usr|boot)\//i,
70
+ /\bsed\s+-i\b/i,
71
+ ];
72
+
73
+ export const RECIPES = [
74
+ {
75
+ id: 'recipe_disk_pressure',
76
+ category: 'system',
77
+ title: 'Disk pressure',
78
+ description: 'Report partitions above 80% usage; stay quiet when everything is comfortable.',
79
+ type: 'script',
80
+ schedule: '0 8 * * *',
81
+ scheduleText: 'Every day at 08:00',
82
+ prompt: "df -h -x tmpfs -x devtmpfs | awk 'NR==1 || $5+0 > 80'",
83
+ channels: ['telegram'],
84
+ silentRule: 'Stay silent when no filesystem in the output is above 80% usage. Otherwise report the offending lines.',
85
+ },
86
+ {
87
+ id: 'recipe_memory_pressure',
88
+ category: 'system',
89
+ title: 'Memory and swap pressure',
90
+ description: 'Report memory and swap state; stay quiet while swap stays untouched.',
91
+ type: 'script',
92
+ schedule: '0 */6 * * *',
93
+ scheduleText: 'Every 6 hours',
94
+ prompt: 'free -m && echo "---" && uptime',
95
+ channels: ['telegram'],
96
+ silentRule: 'Stay silent when swap used is 0 MB and the load average is below the number of CPU cores. Otherwise report the numbers.',
97
+ },
98
+ {
99
+ id: 'recipe_failed_units',
100
+ category: 'services',
101
+ title: 'Failed systemd units',
102
+ description: 'List failed units; stay quiet when the list is empty.',
103
+ type: 'script',
104
+ schedule: '0 * * * *',
105
+ scheduleText: 'Every hour',
106
+ prompt: 'systemctl --failed --no-pager --no-legend',
107
+ channels: ['telegram'],
108
+ silentRule: 'Stay silent when the output lists no failed units. Otherwise report every failed unit.',
109
+ },
110
+ {
111
+ id: 'recipe_journal_errors',
112
+ category: 'services',
113
+ title: 'Errors in the journal',
114
+ description: 'Surface error-level journal entries from the last day.',
115
+ type: 'script',
116
+ schedule: '30 8 * * *',
117
+ scheduleText: 'Every day at 08:30',
118
+ prompt: "journalctl -p err --since '24 hours ago' --no-pager | tail -n 40",
119
+ channels: ['telegram'],
120
+ silentRule: 'Stay silent when the output contains no error lines. Otherwise summarise what is failing repeatedly.',
121
+ },
122
+ {
123
+ id: 'recipe_container_health',
124
+ category: 'services',
125
+ title: 'Container health',
126
+ description: 'List containers that are not Up; skip the task silently without Docker.',
127
+ type: 'script',
128
+ schedule: '*/30 * * * *',
129
+ scheduleText: 'Every 30 minutes',
130
+ prompt: "command -v docker >/dev/null 2>&1 || { echo 'docker is not installed'; exit 0; }; docker ps --format '{{.Names}}\\t{{.Status}}\\t{{.Image}}'",
131
+ channels: ['telegram'],
132
+ silentRule: 'Stay silent when every listed container is Up and no container is restarting. Otherwise report the unhealthy ones.',
133
+ },
134
+ {
135
+ id: 'recipe_log_growth',
136
+ category: 'storage',
137
+ title: 'Log directory growth',
138
+ description: 'Track the size of /var/log and the largest files inside it.',
139
+ type: 'script',
140
+ schedule: '0 9 * * 1',
141
+ scheduleText: 'Mondays at 09:00',
142
+ prompt: "du -sh /var/log 2>/dev/null; du -ah /var/log 2>/dev/null | sort -rh | head -n 10",
143
+ channels: ['telegram'],
144
+ silentRule: 'Stay silent when /var/log is smaller than 1 GB. Otherwise report the total and the largest files.',
145
+ },
146
+ {
147
+ id: 'recipe_backup_freshness',
148
+ category: 'storage',
149
+ title: 'Backup freshness',
150
+ description: 'Show the newest backup files so a stalled backup job becomes visible.',
151
+ type: 'script',
152
+ schedule: '0 7 * * *',
153
+ scheduleText: 'Every day at 07:00',
154
+ prompt: "ls -lt /var/backups 2>/dev/null | head -n 5 || echo 'no /var/backups directory'",
155
+ channels: ['telegram'],
156
+ silentRule: 'Stay silent when the newest backup file is less than 48 hours old. Report out loud when it is older or the directory is missing.',
157
+ },
158
+ {
159
+ id: 'recipe_certificate_expiry',
160
+ category: 'security',
161
+ title: 'Certificate expiry',
162
+ description: 'Report TLS certificates that expire within 30 days.',
163
+ type: 'script',
164
+ schedule: '0 8 * * 1',
165
+ scheduleText: 'Mondays at 08:00',
166
+ prompt: "find /etc/letsencrypt/live -name fullchain.pem 2>/dev/null | while read -r cert; do days=$(( ( $(date -d \"$(openssl x509 -enddate -noout -in \"$cert\" | cut -d= -f2)\" +%s) - $(date +%s) ) / 86400 )); echo \"$days days $cert\"; done",
167
+ channels: ['telegram'],
168
+ silentRule: 'Stay silent when every certificate has more than 30 days left. Report the ones that expire sooner.',
169
+ },
170
+ {
171
+ id: 'recipe_pending_updates',
172
+ category: 'security',
173
+ title: 'Pending package updates',
174
+ description: 'Count upgradable packages without installing anything.',
175
+ type: 'script',
176
+ schedule: '0 10 * * 5',
177
+ scheduleText: 'Fridays at 10:00',
178
+ prompt: "command -v apt >/dev/null 2>&1 || { echo 'apt is not available'; exit 0; }; apt list --upgradable 2>/dev/null | tail -n +2 | wc -l",
179
+ channels: ['telegram'],
180
+ silentRule: 'Stay silent when there are no pending updates. Otherwise report the count and mention that applying them is a manual step.',
181
+ },
182
+ {
183
+ id: 'recipe_uptime_review',
184
+ category: 'maintenance',
185
+ title: 'Weekly uptime review',
186
+ description: 'A short weekly note with uptime, load and the kernel in use.',
187
+ type: 'script',
188
+ schedule: '0 9 * * 1',
189
+ scheduleText: 'Mondays at 09:00',
190
+ prompt: 'uptime && echo "---" && uname -r && echo "---" && df -h / | tail -n 1',
191
+ channels: ['telegram'],
192
+ },
193
+ ];
194
+
195
+ /** Detached copy of the catalog, safe to hand to the UI. */
196
+ export function listRecipes() {
197
+ return RECIPES.map((recipe) => ({ ...recipe, channels: [...(recipe.channels || [])] }));
198
+ }
199
+
200
+ export function recipesByCategory() {
201
+ return RECIPE_CATEGORIES.map((category) => ({
202
+ ...category,
203
+ recipes: listRecipes().filter((recipe) => recipe.category === category.id),
204
+ }));
205
+ }
206
+
207
+ /**
208
+ * The panel's "recommended" list: the same catalog, flattened, so the hub is
209
+ * the single source instead of a second hand-written list (legacy shape kept).
210
+ */
211
+ export function recipeRecommendations(limit = 6) {
212
+ return listRecipes()
213
+ .slice(0, limit)
214
+ .map((recipe) => ({
215
+ id: recipe.id,
216
+ title: recipe.title,
217
+ schedule: recipe.schedule,
218
+ scheduleText: recipe.scheduleText,
219
+ prompt: recipe.prompt,
220
+ description: recipe.description,
221
+ type: recipe.type,
222
+ category: recipe.category,
223
+ channels: recipe.channels,
224
+ silentRule: recipe.silentRule || '',
225
+ }));
226
+ }
227
+
228
+ /** A recipe must never contain a destructive command. */
229
+ export function findDestructiveRecipe(recipes = RECIPES) {
230
+ for (const recipe of recipes) {
231
+ for (const pattern of DESTRUCTIVE_PATTERNS) {
232
+ if (pattern.test(String(recipe.prompt || ''))) {
233
+ return { id: recipe.id, pattern: String(pattern) };
234
+ }
235
+ }
236
+ }
237
+ return null;
238
+ }