@goodandready/dsh-cron 0.3.24 → 0.3.26
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/api-actions.js +1 -317
- package/lib/api-collection.js +1 -250
- package/lib/api-helpers.js +1 -100
- package/lib/api-import-export.js +1 -155
- package/lib/api-item.js +1 -190
- package/lib/api-task-webhook.js +1 -247
- package/lib/api-webhook.js +1 -183
- package/lib/api.js +1 -178
- package/lib/artifacts.js +1 -200
- package/lib/chain-checkpoints.js +1 -223
- package/lib/client-src/00-open.js +62 -0
- package/lib/client-src/10-locales.js +484 -0
- package/lib/client-src/20-styles.js +64 -0
- package/lib/client-src/30-utils.js +248 -0
- package/lib/client-src/40-delivery-settings.js +96 -0
- package/lib/client-src/45-dag-graph-view.js +287 -0
- package/lib/client-src/46-live-logs-modal.js +108 -0
- package/lib/client-src/47-analytics-digest-modal.js +166 -0
- package/lib/client-src/48-timeline-heatmap-view.js +176 -0
- package/lib/client-src/50-cron-screen-state.js +447 -0
- package/lib/client-src/52-cron-screen-actions.js +330 -0
- package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
- package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
- package/lib/client-src/55-cron-screen-view.js +378 -0
- package/lib/client-src/60-modal-task-form-head.js +580 -0
- package/lib/client-src/62-modal-task-form-tail.js +466 -0
- package/lib/client-src/65-modal-dialogs.js +350 -0
- package/lib/client-src/70-settings-card.js +420 -0
- package/lib/client-src/80-sidebar-jobs.js +200 -0
- package/lib/client-src/90-close.js +200 -0
- package/lib/client.js +48 -3
- package/lib/commands.js +200 -0
- package/lib/durable-journal.js +1 -188
- package/lib/external-api.js +1 -74
- package/lib/index.js +17 -0
- package/lib/log-streamer.js +10 -2
- package/lib/metrics.js +1 -125
- package/lib/routes/api-actions.js +317 -0
- package/lib/routes/api-collection.js +250 -0
- package/lib/routes/api-helpers.js +100 -0
- package/lib/routes/api-import-export.js +155 -0
- package/lib/routes/api-item.js +191 -0
- package/lib/routes/api-task-webhook.js +247 -0
- package/lib/routes/api-webhook.js +183 -0
- package/lib/routes/api.js +178 -0
- package/lib/routes/external-api.js +74 -0
- package/lib/routes/index.js +11 -0
- package/lib/routes/metrics.js +125 -0
- package/lib/routes/routes.js +549 -0
- package/lib/routes.js +1 -549
- package/lib/runner-external.js +1 -352
- package/lib/runner-worktree.js +1 -90
- package/lib/runner.js +1 -881
- package/lib/runtimes/index.js +4 -0
- package/lib/runtimes/runner-external.js +352 -0
- package/lib/runtimes/runner-worktree.js +90 -0
- package/lib/runtimes/runner.js +881 -0
- package/lib/runtimes/runtimes.js +198 -0
- package/lib/runtimes.js +1 -198
- package/lib/scheduler/index.js +6 -0
- package/lib/scheduler/scheduler-assists.js +142 -0
- package/lib/scheduler/scheduler-completion.js +417 -0
- package/lib/scheduler/scheduler-execution.js +360 -0
- package/lib/scheduler/scheduler-lifecycle.js +157 -0
- package/lib/scheduler/scheduler-preflight.js +195 -0
- package/lib/scheduler/scheduler.js +751 -0
- package/lib/scheduler-assists.js +1 -142
- package/lib/scheduler-completion.js +1 -417
- package/lib/scheduler-execution.js +1 -360
- package/lib/scheduler-lifecycle.js +1 -157
- package/lib/scheduler-preflight.js +1 -195
- package/lib/scheduler.js +1 -751
- package/lib/store/artifacts.js +200 -0
- package/lib/store/chain-checkpoints.js +223 -0
- package/lib/store/durable-journal.js +188 -0
- package/lib/store/index.js +6 -0
- package/lib/store/store.js +1179 -0
- package/lib/store/task-schema.js +180 -0
- package/lib/store/task-transfer.js +310 -0
- package/lib/store.js +2 -1179
- package/lib/task-schema.js +1 -180
- package/lib/task-transfer.js +1 -310
- package/lib/tui-settings.js +157 -0
- package/package.json +6 -3
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { handleTaskWebhookRoute } from './api-task-webhook.js';
|
|
2
|
+
import { sendJson, rejectCrossOrigin, redactTaskSecrets } from '../http-utils.js';
|
|
3
|
+
import {
|
|
4
|
+
handleTaskExport,
|
|
5
|
+
handleTaskImport,
|
|
6
|
+
applyImportPlan,
|
|
7
|
+
} from './api-import-export.js';
|
|
8
|
+
import { handleTelegramWebhook } from './api-webhook.js';
|
|
9
|
+
import {
|
|
10
|
+
handleHeartbeatPing,
|
|
11
|
+
handleSchedulePreview,
|
|
12
|
+
handleDryRunTask,
|
|
13
|
+
handleScheduleTimeline,
|
|
14
|
+
} from './api-helpers.js';
|
|
15
|
+
import {
|
|
16
|
+
refuseConfigOwned,
|
|
17
|
+
handleTaskCollection,
|
|
18
|
+
createOrUpdateTask,
|
|
19
|
+
NOT_ALLOWED,
|
|
20
|
+
NOT_FOUND,
|
|
21
|
+
} from './api-collection.js';
|
|
22
|
+
import { handleTaskItem, deleteTask } from './api-item.js';
|
|
23
|
+
import { handleItemPost } from './api-actions.js';
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
handleTaskExport,
|
|
27
|
+
handleTaskImport,
|
|
28
|
+
applyImportPlan,
|
|
29
|
+
handleTelegramWebhook,
|
|
30
|
+
handleHeartbeatPing,
|
|
31
|
+
handleSchedulePreview,
|
|
32
|
+
handleDryRunTask,
|
|
33
|
+
handleScheduleTimeline,
|
|
34
|
+
refuseConfigOwned,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
|
|
39
|
+
* route families — the #66 alias is kept alive by the same code path instead
|
|
40
|
+
* of a duplicated handler.
|
|
41
|
+
*
|
|
42
|
+
* IMPORTANT: prefix routes must be registered WITHOUT a trailing slash —
|
|
43
|
+
* with one, the core degrades matching to exact-path equality and every
|
|
44
|
+
* deeper path unrouted (#96). Exported for unit tests.
|
|
45
|
+
*/
|
|
46
|
+
export function createCronApiHandler(store, scheduler, collection) {
|
|
47
|
+
const ctx = collection?.ctx;
|
|
48
|
+
return async function handleCronApi(req, res) {
|
|
49
|
+
try {
|
|
50
|
+
const apiToken = store?.getSettings?.()?.apiToken;
|
|
51
|
+
const authOptions = { apiToken, ctx };
|
|
52
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
53
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
54
|
+
const scope = parts[1]; // 'tasks' | 'action' | 'heartbeat-ping'
|
|
55
|
+
const id = parts[2];
|
|
56
|
+
const action = parts[3];
|
|
57
|
+
const subAction = parts[4];
|
|
58
|
+
|
|
59
|
+
if (scope === 'telegram' && id === 'webhook') {
|
|
60
|
+
await handleTelegramWebhook({ store, scheduler, req, res });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (scope === 'webhook' && id) {
|
|
64
|
+
await handleTaskWebhookRoute({ store, scheduler, req, res, taskId: id });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if ((scope === 'heartbeat' || scope === 'heartbeat-ping') && id) {
|
|
69
|
+
handleHeartbeatPing({ store, req, res, taskId: id, apiToken, ctx });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (scope === 'schedule' && id === 'preview') {
|
|
74
|
+
await handleSchedulePreview({ req, res, url, store, apiToken, ctx });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (scope === 'schedule' && id === 'timeline-24h') {
|
|
78
|
+
await handleScheduleTimeline({ req, res, url, store, scheduler, apiToken, ctx });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!id) {
|
|
83
|
+
await handleTaskCollection({ store, scheduler, collection, req, res, url, scope, apiToken, ctx });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (rejectCrossOrigin(req, res, { apiToken, ctx })) return;
|
|
88
|
+
|
|
89
|
+
// Export/import are collection operations that live under /tasks/:id/…,
|
|
90
|
+
// so they are matched before the generic id handling below.
|
|
91
|
+
if (id === 'export') {
|
|
92
|
+
if (req.method !== 'GET') {
|
|
93
|
+
sendJson(res, 405, NOT_ALLOWED);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
handleTaskExport({ store, req, res });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (id === 'import') {
|
|
100
|
+
if (req.method !== 'POST') {
|
|
101
|
+
sendJson(res, 405, NOT_ALLOWED);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await handleTaskImport({ store, scheduler, req, res });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await handleTaskItem({ store, scheduler, req, res, url, id, action, subAction, apiToken, ctx });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --------------------------------------------------------- external surface
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Task operations for the token-guarded /dsh-cron/api/* prefix (#54). The shape
|
|
119
|
+
* is deliberately small — list, read, create or update, delete, manual run —
|
|
120
|
+
* and reuses the handlers above, so the confirmation gate for code-executing
|
|
121
|
+
* tasks and the config-owned refusals are the same code, not a second copy.
|
|
122
|
+
*/
|
|
123
|
+
export async function handleExternalTaskRequest({ store, scheduler, req, res, url, apiToken }) {
|
|
124
|
+
const parts = url.pathname.split('/').filter(Boolean); // dsh-cron, api, [tasks|heartbeat|schedule], :id?, :action?
|
|
125
|
+
if (parts[2] === 'webhook' && parts[3]) {
|
|
126
|
+
await handleTaskWebhookRoute({ store, scheduler, req, res, taskId: parts[3] });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (parts[2] === 'heartbeat' && parts[3]) {
|
|
130
|
+
handleHeartbeatPing({ store, req, res, taskId: parts[3], apiToken });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (parts[2] === 'schedule' && parts[3] === 'preview') {
|
|
134
|
+
await handleSchedulePreview({ req, res, url, store, apiToken });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const id = parts[3];
|
|
138
|
+
const action = parts[4];
|
|
139
|
+
const subAction = parts[5];
|
|
140
|
+
|
|
141
|
+
if (!id) {
|
|
142
|
+
if (req.method === 'GET') {
|
|
143
|
+
const list = store
|
|
144
|
+
.list({ status: url.searchParams.get('status') || 'all', query: url.searchParams.get('query') || '' })
|
|
145
|
+
.map((task) => ({ ...redactTaskSecrets(task), running: scheduler.isRunning(task.id) }));
|
|
146
|
+
sendJson(res, 200, { ok: true, tasks: list });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (req.method === 'POST') {
|
|
150
|
+
await createOrUpdateTask({ store, scheduler, req, res, apiToken, ctx: undefined });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
sendJson(res, 405, NOT_ALLOWED);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (req.method === 'POST') {
|
|
158
|
+
const handled = await handleItemPost({ store, scheduler, req, res, url, id, action, subAction });
|
|
159
|
+
if (handled) return;
|
|
160
|
+
}
|
|
161
|
+
if (req.method === 'GET' && !action) {
|
|
162
|
+
const task = store.get(id);
|
|
163
|
+
if (!task) {
|
|
164
|
+
sendJson(res, 404, NOT_FOUND);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
sendJson(res, 200, { ok: true, task: { ...redactTaskSecrets(task), running: scheduler.isRunning(id) } });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (req.method === 'DELETE' && !action) {
|
|
171
|
+
deleteTask({ store, scheduler, res, id });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
sendJson(res, 405, NOT_ALLOWED);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// webhook and helper endpoints moved to ./api-webhook.js and ./api-helpers.js
|
|
178
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External REST surface under /dsh-cron/api/* (#54, ADR-0001).
|
|
3
|
+
*
|
|
4
|
+
* Everything a browser panel needs stays on the local, cross-origin-protected
|
|
5
|
+
* routes; this prefix exists for CI and host-side automation, so it is the only
|
|
6
|
+
* surface guarded by a bearer token. The token lives in the plugin settings as
|
|
7
|
+
* a masked value: an unconfigured token disables the surface (503) instead of
|
|
8
|
+
* leaving it open, and a wrong token is answered with 401.
|
|
9
|
+
*
|
|
10
|
+
* The operations themselves are shared with the panel routes (lib/api.js), so
|
|
11
|
+
* the confirmation gate for code-executing tasks and the config-owned refusals
|
|
12
|
+
* apply here exactly as they do over HTTP from the UI.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
16
|
+
import { sendJson } from '../http-utils.js';
|
|
17
|
+
import { handleExternalTaskRequest } from './api.js';
|
|
18
|
+
|
|
19
|
+
/** Settings key that holds the bearer token. */
|
|
20
|
+
export const API_TOKEN_SETTING_KEY = 'apiToken';
|
|
21
|
+
|
|
22
|
+
/** Route prefix owned by this surface. */
|
|
23
|
+
export const EXTERNAL_API_PREFIX = '/dsh-cron/api';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Constant-time token comparison. Length is compared first because
|
|
27
|
+
* timingSafeEqual throws on buffers of different sizes; a length mismatch is
|
|
28
|
+
* not a secret worth hiding for a fixed-length token.
|
|
29
|
+
*/
|
|
30
|
+
export function tokenMatches(expected, provided) {
|
|
31
|
+
if (!expected || !provided) return false;
|
|
32
|
+
const a = Buffer.from(String(expected), 'utf8');
|
|
33
|
+
const b = Buffer.from(String(provided), 'utf8');
|
|
34
|
+
if (a.length !== b.length) return false;
|
|
35
|
+
return timingSafeEqual(a, b);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Bearer token of a request, or an empty string when there is none. */
|
|
39
|
+
export function readBearerToken(req) {
|
|
40
|
+
const header = String((req.headers && req.headers.authorization) || '');
|
|
41
|
+
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
42
|
+
return match ? match[1].trim() : '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Guard the external prefix, then hand the request to the shared task
|
|
47
|
+
* operations. `getToken` is read per request so a settings change applies
|
|
48
|
+
* without a restart.
|
|
49
|
+
*/
|
|
50
|
+
export function createExternalApiHandler({ store, scheduler, getToken }) {
|
|
51
|
+
return async function handleExternalApi(req, res) {
|
|
52
|
+
const expected = typeof getToken === 'function' ? getToken() : '';
|
|
53
|
+
if (!expected) {
|
|
54
|
+
sendJson(res, 503, {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: 'External API is disabled: set the "' + API_TOKEN_SETTING_KEY + '" setting to enable it',
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!tokenMatches(expected, readBearerToken(req))) {
|
|
61
|
+
sendJson(res, 401, { ok: false, error: 'Unauthorized' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
65
|
+
// `url` must be inside the try: a malformed body (an unparseable schedule,
|
|
66
|
+
// for example) surfaces as a thrown error from the shared handlers, and an
|
|
67
|
+
// unhandled rejection here would answer nothing at all.
|
|
68
|
+
try {
|
|
69
|
+
await handleExternalTaskRequest({ store, scheduler, req, res, url, apiToken: expected });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
sendJson(res, (err && err.statusCode) || 500, { ok: false, error: (err && err.message) || String(err) });
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './api-collection.js';
|
|
2
|
+
export * from './api-item.js';
|
|
3
|
+
export * from './api-actions.js';
|
|
4
|
+
export * from './api-import-export.js';
|
|
5
|
+
export * from './api-task-webhook.js';
|
|
6
|
+
export * from './api-webhook.js';
|
|
7
|
+
export * from './api-helpers.js';
|
|
8
|
+
export * from './metrics.js';
|
|
9
|
+
export * from './external-api.js';
|
|
10
|
+
export * from './routes.js';
|
|
11
|
+
export * from './api.js';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { rejectCrossOrigin } from '../http-utils.js';
|
|
2
|
+
/**
|
|
3
|
+
* Prometheus exposition of the scheduler (#53).
|
|
4
|
+
*
|
|
5
|
+
* The renderer is hand-rolled on purpose: the profile has no prom-client and
|
|
6
|
+
* adding a production dependency for a handful of counters is not justified.
|
|
7
|
+
* Only counts, statuses and durations are exported — never prompts, run output
|
|
8
|
+
* or task configuration, so the endpoint can be scraped without leaking work
|
|
9
|
+
* content.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
|
|
13
|
+
|
|
14
|
+
/** Run statuses the scheduler can finish with. */
|
|
15
|
+
export const RUN_STATUSES = ['success', 'error', 'timeout', 'skipped', 'missed'];
|
|
16
|
+
|
|
17
|
+
const NL = String.fromCharCode(10);
|
|
18
|
+
const BACKSLASH = String.fromCharCode(92);
|
|
19
|
+
const QUOTE = String.fromCharCode(34);
|
|
20
|
+
|
|
21
|
+
/** Escape a label value for the Prometheus text format. */
|
|
22
|
+
export function escapeLabel(value) {
|
|
23
|
+
return String(value)
|
|
24
|
+
.split(BACKSLASH).join(BACKSLASH + BACKSLASH)
|
|
25
|
+
.split(QUOTE).join(BACKSLASH + QUOTE)
|
|
26
|
+
.split(NL).join(BACKSLASH + 'n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sample(name, labels, value) {
|
|
30
|
+
const rendered = Object.keys(labels)
|
|
31
|
+
.map((key) => key + '=' + QUOTE + escapeLabel(labels[key]) + QUOTE)
|
|
32
|
+
.join(',');
|
|
33
|
+
return name + (rendered ? '{' + rendered + '}' : '') + ' ' + value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render the current scheduler state as Prometheus text exposition.
|
|
38
|
+
* Pure function over plain data so it is testable without a store.
|
|
39
|
+
*/
|
|
40
|
+
export function renderMetrics({ tasks = [], stats = {}, runCounters = {}, scheduler } = {}) {
|
|
41
|
+
const lines = [];
|
|
42
|
+
const byStatus = {};
|
|
43
|
+
for (const task of tasks) {
|
|
44
|
+
const status = task.status || 'unknown';
|
|
45
|
+
byStatus[status] = (byStatus[status] || 0) + 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
lines.push('# HELP dsh_cron_tasks_total Scheduled tasks by status.');
|
|
49
|
+
// A gauge despite the name: this is a snapshot of how many tasks are in each
|
|
50
|
+
// state, not a monotonically growing total.
|
|
51
|
+
lines.push('# TYPE dsh_cron_tasks_total gauge');
|
|
52
|
+
for (const status of Object.keys(byStatus).sort()) {
|
|
53
|
+
lines.push(sample('dsh_cron_tasks_total', { status }, byStatus[status]));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lines.push('# HELP dsh_cron_task_last_duration_seconds Duration of the last finished run of a task.');
|
|
57
|
+
lines.push('# TYPE dsh_cron_task_last_duration_seconds gauge');
|
|
58
|
+
for (const task of tasks) {
|
|
59
|
+
// lastRunAt marks a finished run: a task that never ran still carries
|
|
60
|
+
// lastDurationMs = 0, and exporting that would show instant runs in
|
|
61
|
+
// monitoring.
|
|
62
|
+
if (!Number.isFinite(task.lastDurationMs) || !task.lastRunAt) continue;
|
|
63
|
+
lines.push(sample('dsh_cron_task_last_duration_seconds', { task: task.id }, task.lastDurationMs / 1000));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
lines.push('# HELP dsh_cron_runs_total Finished runs since the plugin started, by status.');
|
|
67
|
+
lines.push('# TYPE dsh_cron_runs_total counter');
|
|
68
|
+
for (const status of RUN_STATUSES) {
|
|
69
|
+
lines.push(sample('dsh_cron_runs_total', { status }, Number(runCounters[status]) || 0));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
lines.push('# HELP dsh_cron_run_records Run records currently kept in memory.');
|
|
73
|
+
lines.push('# TYPE dsh_cron_run_records gauge');
|
|
74
|
+
lines.push('dsh_cron_run_records ' + (Number(stats.totalRuns) || 0));
|
|
75
|
+
|
|
76
|
+
if (scheduler && typeof scheduler.runningCount === 'function') {
|
|
77
|
+
lines.push('# HELP dsh_cron_concurrent_running Number of currently executing tasks.');
|
|
78
|
+
lines.push('# TYPE dsh_cron_concurrent_running gauge');
|
|
79
|
+
lines.push('dsh_cron_concurrent_running ' + (Number(scheduler.runningCount()) || 0));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
lines.push('# HELP dsh_cron_task_tokens_total Cumulative tokens consumed by task.');
|
|
83
|
+
lines.push('# TYPE dsh_cron_task_tokens_total counter');
|
|
84
|
+
for (const task of tasks) {
|
|
85
|
+
if (Number(task.totalTokens) > 0) {
|
|
86
|
+
lines.push(sample('dsh_cron_task_tokens_total', { task: task.id }, task.totalTokens));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
lines.push('# HELP dsh_cron_task_cost_usd_total Estimated cumulative LLM cost in USD.');
|
|
91
|
+
lines.push('# TYPE dsh_cron_task_cost_usd_total counter');
|
|
92
|
+
for (const task of tasks) {
|
|
93
|
+
if (Number(task.totalCostUsd) > 0) {
|
|
94
|
+
lines.push(sample('dsh_cron_task_cost_usd_total', { task: task.id }, task.totalCostUsd));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return lines.join(NL) + NL;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* GET /dsh-cron/metrics — read-only. A scraper is a plain local client, so no
|
|
103
|
+
* cross-origin check applies, and nothing sensitive is exposed.
|
|
104
|
+
*/
|
|
105
|
+
export function createMetricsHandler({ store, scheduler }) {
|
|
106
|
+
return function handleMetrics(req, res) {
|
|
107
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
108
|
+
res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
109
|
+
res.end('Method not allowed' + NL);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const settings = store && typeof store.getSettings === 'function' ? store.getSettings() : {};
|
|
113
|
+
const apiToken = settings.apiToken;
|
|
114
|
+
if (rejectCrossOrigin(req, res, { apiToken })) return;
|
|
115
|
+
|
|
116
|
+
const body = renderMetrics({
|
|
117
|
+
tasks: store.list({ status: 'all' }),
|
|
118
|
+
stats: store.getAggregatedStats(),
|
|
119
|
+
runCounters: typeof scheduler.getRunCounters === 'function' ? scheduler.getRunCounters() : {},
|
|
120
|
+
scheduler,
|
|
121
|
+
});
|
|
122
|
+
res.writeHead(200, { 'Content-Type': METRICS_CONTENT_TYPE, 'Cache-Control': 'no-store' });
|
|
123
|
+
res.end(req.method === 'HEAD' ? undefined : body);
|
|
124
|
+
};
|
|
125
|
+
}
|