@goodandready/dsh-cron 0.2.50 → 0.2.51
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.js +30 -0
- package/lib/channels.js +4 -2
- package/lib/client.js +80 -89
- package/lib/cron-tool.js +20 -0
- package/lib/index.js +4 -1
- package/lib/scheduler-execution.js +99 -5
- package/lib/scheduler.js +12 -0
- package/lib/settings.js +1 -1
- package/lib/store.js +10 -1
- package/lib/task-create.js +2 -0
- package/lib/task-patch.js +4 -0
- package/lib/task-transfer.js +1 -0
- package/lib/telegram.js +1 -1
- package/lib/validators.js +192 -0
- package/package.json +1 -1
package/lib/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeValidators } from './validators.js';
|
|
1
2
|
/**
|
|
2
3
|
* REST surface of the plugin: the /dsh-cron/tasks and /dsh-cron/action route
|
|
3
4
|
* families. Extracted from index.js and split into per-scope handlers so each
|
|
@@ -266,6 +267,7 @@ function buildTaskRecord(store, body, taskType, parsed) {
|
|
|
266
267
|
maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
|
|
267
268
|
retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
|
|
268
269
|
permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current && !EXTERNAL_RUNTIMES.includes(taskType) ? (current.permissionPreset || 'default') : 'default'),
|
|
270
|
+
validators: Array.isArray(body.validators) ? sanitizeValidators(body.validators) : (current ? current.validators : undefined),
|
|
269
271
|
...mergeExecutionFields(current, body, parsed),
|
|
270
272
|
};
|
|
271
273
|
}
|
|
@@ -341,6 +343,34 @@ async function handleTaskItem({ store, scheduler, req, res, url, id, action, api
|
|
|
341
343
|
|
|
342
344
|
/** Task actions that are plain POSTs. Returns true when one matched. */
|
|
343
345
|
async function handleItemPost({ store, scheduler, req, res, url, id, action }) {
|
|
346
|
+
if (action === 'retry-delivery') {
|
|
347
|
+
const task = store.get(id);
|
|
348
|
+
if (!task) {
|
|
349
|
+
sendJson(res, 404, NOT_FOUND);
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
const body = await readBodyJson(req).catch(() => ({}));
|
|
353
|
+
const runId = body.runId || url.searchParams.get('runId');
|
|
354
|
+
const runs = store.history.get(id) || [];
|
|
355
|
+
const run = runId ? runs.find(r => r.id === runId) : runs[0];
|
|
356
|
+
if (!run) {
|
|
357
|
+
sendJson(res, 404, { ok: false, error: 'Run not found' });
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
const delivery = await scheduler.deliverNotifications(task, run);
|
|
361
|
+
run.delivery = delivery;
|
|
362
|
+
if (Array.isArray(run.stages)) {
|
|
363
|
+
const deliveryStage = run.stages.find(s => s.name === 'delivery');
|
|
364
|
+
if (deliveryStage) {
|
|
365
|
+
deliveryStage.status = delivery?.status || 'delivered';
|
|
366
|
+
deliveryStage.delivered = delivery?.delivered;
|
|
367
|
+
deliveryStage.failures = delivery?.failures;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
store.scheduleSave();
|
|
371
|
+
sendJson(res, 200, { ok: true, delivery, run });
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
344
374
|
if (action === 'run') {
|
|
345
375
|
if (!store.get(id)) {
|
|
346
376
|
sendJson(res, 404, NOT_FOUND);
|
package/lib/channels.js
CHANGED
|
@@ -15,7 +15,7 @@ export const CHANNEL_IDS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'b
|
|
|
15
15
|
|
|
16
16
|
// CHANNEL_LABELS removed (#155) — localized via lib/client.js
|
|
17
17
|
|
|
18
|
-
export const isFailureStatus = (status) => status === 'error' || status === 'timeout' || status === 'missed';
|
|
18
|
+
export const isFailureStatus = (status) => status === 'error' || status === 'timeout' || status === 'missed' || status === 'validation_failed' || status === 'validator_error';
|
|
19
19
|
export const isFailed = (status) => isFailureStatus(status);
|
|
20
20
|
|
|
21
21
|
/** The message text for a channel: task template -> channel template -> global template -> built-in defaults (#233). */
|
|
@@ -362,7 +362,9 @@ const CHANNEL_HANDLERS = {
|
|
|
362
362
|
},
|
|
363
363
|
|
|
364
364
|
async tts({ task, runInfo, settings, http, signal, timeoutMs }) {
|
|
365
|
-
const
|
|
365
|
+
const defaultPort = settings._webServerPort || settings.webServerPort || (settings.webServer && settings.webServer.port) || 3080;
|
|
366
|
+
const rawBase = String(settings.ttsBaseUrl || '').trim();
|
|
367
|
+
const base = (rawBase ? rawBase : `http://127.0.0.1:${defaultPort}`).replace(/\/+$/, '');
|
|
366
368
|
const payload = { text: truncateText(messageTextFor('tts', task, runInfo, settings), 600) };
|
|
367
369
|
const data = await postJson(http, `${base}/dsh-tts/speak`, { 'Content-Type': 'application/json' }, JSON.stringify(payload), 'tts', signal, timeoutMs);
|
|
368
370
|
return { provider: data.provider || 'dsh-tts', tookMs: data.tookMs || null };
|
package/lib/client.js
CHANGED
|
@@ -6,20 +6,14 @@ window.__ModuleLoader__.load({
|
|
|
6
6
|
const React = window.React || require('react');
|
|
7
7
|
const ReactDOM = window.ReactDOM || require('react-dom/client');
|
|
8
8
|
|
|
9
|
-
// Settings namespace, locale namespace and plugin marker share the same
|
|
10
|
-
// canonical identifier — the settings.plugin.item slot key MUST match the
|
|
11
|
-
// namespace registered on the server ('dsh-cron', Issue #85).
|
|
12
9
|
const PLUGIN_ID = '@goodandready/dsh-cron';
|
|
13
10
|
const NS = 'dsh-cron';
|
|
14
|
-
// Plugins page row seat (DSH 0.1.6-alpha.2): key = '<package name>#<row id>'.
|
|
15
11
|
const ROW_ID = 'dsh-cron';
|
|
16
12
|
const ROW_CONFIG_KEY = PLUGIN_ID + '#' + ROW_ID;
|
|
17
13
|
const PANEL = 'cron';
|
|
18
14
|
const ACTIVATE_EVENT = 'dsh-panel-activate';
|
|
19
15
|
const ACTIVE_ATTR = 'data-dsh-cron-active';
|
|
20
16
|
const ENTRY_ATTR = 'data-dsh-cron-entry';
|
|
21
|
-
// Set by the sidebar job list (#34) so the panel can highlight the task the
|
|
22
|
-
// user clicked; cleared once the panel has scrolled to it.
|
|
23
17
|
let pendingTaskHighlight = null;
|
|
24
18
|
const VIEW_ATTR = 'data-dsh-cron-view';
|
|
25
19
|
const COLUMN_SELECTOR = '[data-pane=conversation], [class*=centerCol]';
|
|
@@ -44,10 +38,6 @@ window.__ModuleLoader__.load({
|
|
|
44
38
|
const ICON_UPLOAD = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>';
|
|
45
39
|
const ICON_TELEGRAM = '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8-1.7 8.01c-.13.58-.47.72-.95.45l-2.6-1.92-1.25 1.21c-.14.14-.26.26-.53.26l.19-2.64 4.81-4.35c.21-.19-.05-.29-.32-.11L8.34 13.5 5.78 12.7c-.56-.17-.57-.56.12-.83l10-3.86c.46-.17.87.11.74.79z"/></svg>';
|
|
46
40
|
|
|
47
|
-
// Locale dictionary: English is the canonical source language (Issue #87).
|
|
48
|
-
// Russian and other languages are provided at runtime by the translation
|
|
49
|
-
// plugin; no per-language duplicates are stored here.
|
|
50
|
-
// Core chevron primitive when the build ships it; own SVG fallback otherwise.
|
|
51
41
|
let CoreChevron = null;
|
|
52
42
|
try {
|
|
53
43
|
const primitives = require('@deepseek-ai/dsh-client-ui-primitives');
|
|
@@ -57,9 +47,6 @@ window.__ModuleLoader__.load({
|
|
|
57
47
|
}
|
|
58
48
|
const chevronNode = () => (CoreChevron ? React.createElement(CoreChevron) : React.createElement('span', { style: { display: 'flex', alignItems: 'center' }, dangerouslySetInnerHTML: { __html: ICON_CHEVRON_DOWN } }));
|
|
59
49
|
|
|
60
|
-
// Own React error boundary (#106): the core's SlotErrorBoundary renders an
|
|
61
|
-
// empty div on crash, so a slot failure would silently vanish. Ours keeps
|
|
62
|
-
// the failure visible with a retry.
|
|
63
50
|
const STRINGS = {
|
|
64
51
|
en: {
|
|
65
52
|
'sidebar.label': 'Scheduled tasks',
|
|
@@ -143,7 +130,25 @@ window.__ModuleLoader__.load({
|
|
|
143
130
|
'modal.close': 'Close',
|
|
144
131
|
'history.loading': 'Loading history...',
|
|
145
132
|
'history.empty': 'No runs recorded yet',
|
|
133
|
+
'history.stoppedReasonTitle': 'Stopped reason:',
|
|
134
|
+
'history.retryDelivery': 'Retry delivery',
|
|
135
|
+
'history.retryingDelivery': 'Retrying...',
|
|
136
|
+
'history.statusValidationFailed': 'validation failed',
|
|
137
|
+
'history.stage_queue': 'Queue',
|
|
138
|
+
'history.stage_preflight': 'Preflight',
|
|
139
|
+
'history.stage_execution': 'Execution',
|
|
140
|
+
'history.stage_validation': 'Validation',
|
|
141
|
+
'history.stage_delivery': 'Delivery',
|
|
146
142
|
'history.refresh': 'Refresh history',
|
|
143
|
+
'history.stoppedReasonTitle': '停止原因:',
|
|
144
|
+
'history.retryDelivery': '重试推送',
|
|
145
|
+
'history.retryingDelivery': '重试中...',
|
|
146
|
+
'history.statusValidationFailed': '校验未通过',
|
|
147
|
+
'history.stage_queue': '排队',
|
|
148
|
+
'history.stage_preflight': '前置检查',
|
|
149
|
+
'history.stage_execution': '执行',
|
|
150
|
+
'history.stage_validation': '校验',
|
|
151
|
+
'history.stage_delivery': '推送',
|
|
147
152
|
'history.statusSuccess': 'Success',
|
|
148
153
|
'history.statusSkipped': 'Skipped',
|
|
149
154
|
'history.statusMissed': 'Missed',
|
|
@@ -615,8 +620,6 @@ window.__ModuleLoader__.load({
|
|
|
615
620
|
}
|
|
616
621
|
};
|
|
617
622
|
|
|
618
|
-
// Module-level translator. apply() rebinds it to the DSH locale service so
|
|
619
|
-
// the active language and live language switches are honored.
|
|
620
623
|
let translate = (key, vars) => {
|
|
621
624
|
let s = (STRINGS.en && STRINGS.en[key]) || key;
|
|
622
625
|
if (vars) {
|
|
@@ -774,6 +777,12 @@ window.__ModuleLoader__.load({
|
|
|
774
777
|
.dsh-cron-history-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
|
775
778
|
.dsh-cron-history-status { font-weight: 500; font-size: 11px; text-transform: uppercase; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2); }
|
|
776
779
|
.dsh-cron-history-status[data-status="success"] { background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); border-color: var(--dsh-cron-success); }
|
|
780
|
+
.dsh-cron-history-status[data-status="validation_failed"] { background: var(--dsh-cron-danger-bg); color: var(--dsh-cron-danger); border-color: var(--dsh-cron-danger); }
|
|
781
|
+
.dsh-cron-stage-pill { font-size: 10.5px; padding: 1px 7px; border-radius: 4px; border: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-secondary); }
|
|
782
|
+
.dsh-cron-stage-pill[data-status="success"], .dsh-cron-stage-pill[data-status="delivered"] { background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); border-color: var(--dsh-cron-success); }
|
|
783
|
+
.dsh-cron-stage-pill[data-status="failed"], .dsh-cron-stage-pill[data-status="error"] { background: var(--dsh-cron-danger-bg); color: var(--dsh-cron-danger); border-color: var(--dsh-cron-danger); }
|
|
784
|
+
.dsh-cron-stage-pill[data-status="partial"] { background: var(--dsh-cron-warning-bg); color: var(--dsh-cron-warning); border-color: var(--dsh-cron-warning); }
|
|
785
|
+
.dsh-cron-stage-pill[data-status="skipped"] { opacity: 0.6; }
|
|
777
786
|
.dsh-cron-history-status[data-status="error"], .dsh-cron-history-status[data-status="timeout"] { background: var(--dsh-cron-danger-bg); color: var(--dsh-cron-danger); border-color: var(--dsh-cron-danger); }
|
|
778
787
|
.dsh-cron-history-status[data-status="skipped"], .dsh-cron-history-status[data-status="missed"] { background: var(--dsh-cron-warning-bg); color: var(--dsh-cron-warning); border-color: var(--dsh-cron-warning); }
|
|
779
788
|
.dsh-cron-history-time { color: var(--dsw-alias-label-tertiary); font-size: 12px; }
|
|
@@ -861,7 +870,6 @@ window.__ModuleLoader__.load({
|
|
|
861
870
|
if (!head) return null;
|
|
862
871
|
el = document.createElement('style');
|
|
863
872
|
el.id = STYLE_ID;
|
|
864
|
-
// Mark ownership before insertion so neighbor cleanups leave it alone (#91)
|
|
865
873
|
el.dataset.dshPlugin = 'dsh-cron';
|
|
866
874
|
el.textContent = STYLES;
|
|
867
875
|
head.appendChild(el);
|
|
@@ -871,8 +879,6 @@ window.__ModuleLoader__.load({
|
|
|
871
879
|
return el;
|
|
872
880
|
}
|
|
873
881
|
|
|
874
|
-
// Registry is shared across hot reloads so the newest instance owns exactly
|
|
875
|
-
// one watcher instead of stacking one per module evaluation.
|
|
876
882
|
const STYLE_HEAL_KEY = '__dshCronStyleHeal';
|
|
877
883
|
const STYLE_HEAL_INTERVAL_MS = 2000;
|
|
878
884
|
|
|
@@ -947,7 +953,6 @@ window.__ModuleLoader__.load({
|
|
|
947
953
|
const AGENT_FORM_TYPES = ['llm', 'skill', 'workflow'];
|
|
948
954
|
const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
|
|
949
955
|
|
|
950
|
-
// Delivery channels (#26) — must stay in sync with lib/channels.js.
|
|
951
956
|
const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
|
|
952
957
|
const CHANNEL_LABEL_KEYS = {
|
|
953
958
|
telegram: 'form.channelTelegram',
|
|
@@ -961,15 +966,11 @@ window.__ModuleLoader__.load({
|
|
|
961
966
|
gitea: 'form.channelGitea',
|
|
962
967
|
};
|
|
963
968
|
|
|
964
|
-
// Non-secret delivery settings shown in the settings panel. Secrets are
|
|
965
|
-
// referenced by credential name only (#51) — never typed as values.
|
|
966
969
|
const DELIVERY_SETTING_FIELDS = [
|
|
967
970
|
{ key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
|
|
968
971
|
{ key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
|
|
969
972
|
{ key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
|
|
970
973
|
{ key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
|
|
971
|
-
// The external API is the one surface where a typed secret is the point:
|
|
972
|
-
// a CI job cannot present a credential reference (#54).
|
|
973
974
|
{ key: 'apiToken', labelKey: 'settings.apiTokenLabel', placeholderKey: 'settings.apiTokenPlaceholder', group: 'secrets' },
|
|
974
975
|
{ key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
|
|
975
976
|
{ key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
|
|
@@ -986,7 +987,6 @@ window.__ModuleLoader__.load({
|
|
|
986
987
|
{ key: 'inspectorModel', labelKey: 'settings.inspectorModelLabel', group: 'automation' },
|
|
987
988
|
];
|
|
988
989
|
const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
|
|
989
|
-
// The global template plus the per-channel override map (server model).
|
|
990
990
|
const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
|
|
991
991
|
|
|
992
992
|
/** Collect every delivery key the settings endpoints may return. */
|
|
@@ -1009,8 +1009,6 @@ window.__ModuleLoader__.load({
|
|
|
1009
1009
|
return Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n');
|
|
1010
1010
|
}
|
|
1011
1011
|
|
|
1012
|
-
// Status colors: core semantic state tokens first (same set dsh-clinebot
|
|
1013
|
-
// uses), plugin fallbacks as a single point of change (#105).
|
|
1014
1012
|
function createToggle() {
|
|
1015
1013
|
let open = false;
|
|
1016
1014
|
const listeners = new Set();
|
|
@@ -1037,7 +1035,6 @@ window.__ModuleLoader__.load({
|
|
|
1037
1035
|
const getSessions = () => (ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : (ctx && ctx.sessions));
|
|
1038
1036
|
let sessions = getSessions();
|
|
1039
1037
|
|
|
1040
|
-
// 1. Try sessions.open(sessionId) immediately
|
|
1041
1038
|
if (sessions && typeof sessions.open === 'function') {
|
|
1042
1039
|
try {
|
|
1043
1040
|
sessions.open(sessionId);
|
|
@@ -1047,7 +1044,6 @@ window.__ModuleLoader__.load({
|
|
|
1047
1044
|
}
|
|
1048
1045
|
}
|
|
1049
1046
|
|
|
1050
|
-
// 2. If the session is not in manager.summaries yet, refresh from the backend
|
|
1051
1047
|
if (sessions && typeof sessions.refresh === 'function') {
|
|
1052
1048
|
try {
|
|
1053
1049
|
await sessions.refresh();
|
|
@@ -1058,7 +1054,6 @@ window.__ModuleLoader__.load({
|
|
|
1058
1054
|
}
|
|
1059
1055
|
}
|
|
1060
1056
|
|
|
1061
|
-
// 3. Async retries until the WebSocket/SSE event adds the session to the list
|
|
1062
1057
|
for (let i = 0; i < 15; i++) {
|
|
1063
1058
|
await new Promise(resolve => setTimeout(resolve, 120));
|
|
1064
1059
|
sessions = getSessions();
|
|
@@ -1070,7 +1065,6 @@ window.__ModuleLoader__.load({
|
|
|
1070
1065
|
/* retry polling sessions.open threw */
|
|
1071
1066
|
}
|
|
1072
1067
|
}
|
|
1073
|
-
// DOM fallback: click the session in the sidebar if it has rendered
|
|
1074
1068
|
if (typeof document !== 'undefined') {
|
|
1075
1069
|
const domItem = document.querySelector('[data-session-id="' + sessionId + '"], [role="treeitem"][data-id="' + sessionId + '"], [data-id="' + sessionId + '"]');
|
|
1076
1070
|
if (domItem) {
|
|
@@ -1085,6 +1079,7 @@ window.__ModuleLoader__.load({
|
|
|
1085
1079
|
|
|
1086
1080
|
function statusLabel(t, status) {
|
|
1087
1081
|
if (status === 'success') return T_KEY(t, 'history.statusSuccess');
|
|
1082
|
+
if (status === 'validation_failed') return T_KEY(t, 'history.statusValidationFailed');
|
|
1088
1083
|
if (status === 'skipped') return T_KEY(t, 'history.statusSkipped');
|
|
1089
1084
|
if (status === 'missed') return T_KEY(t, 'history.statusMissed');
|
|
1090
1085
|
if (status === 'timeout') return T_KEY(t, 'history.statusTimeout');
|
|
@@ -1178,9 +1173,6 @@ window.__ModuleLoader__.load({
|
|
|
1178
1173
|
step: 1000,
|
|
1179
1174
|
value: val(f.key),
|
|
1180
1175
|
placeholder: String(f.defaultValue !== undefined ? f.defaultValue : 15000),
|
|
1181
|
-
// #115: clearing the field must send the explicit default. Deleting the
|
|
1182
|
-
// key would leave the previously stored value in place (the settings
|
|
1183
|
-
// scope skips absent keys), so the field would silently keep 1 ms.
|
|
1184
1176
|
onChange: (e) => onChange(f.key, e.target.value === ''
|
|
1185
1177
|
? (f.defaultValue !== undefined ? f.defaultValue : 15000)
|
|
1186
1178
|
: Number(e.target.value)),
|
|
@@ -1253,8 +1245,6 @@ window.__ModuleLoader__.load({
|
|
|
1253
1245
|
const t = props.t || translate;
|
|
1254
1246
|
const [tab, setTab] = React.useState('all');
|
|
1255
1247
|
const [query, setQuery] = React.useState('');
|
|
1256
|
-
// Facet filters (#40): applied client-side over the already-loaded list so
|
|
1257
|
-
// toggling them is instant and does not touch the polling request.
|
|
1258
1248
|
const [filterType, setFilterType] = React.useState('');
|
|
1259
1249
|
const [filterModel, setFilterModel] = React.useState('');
|
|
1260
1250
|
const [filterChannel, setFilterChannel] = React.useState('');
|
|
@@ -1265,14 +1255,12 @@ window.__ModuleLoader__.load({
|
|
|
1265
1255
|
const [fetchError, setFetchError] = React.useState(null);
|
|
1266
1256
|
const [dropdownOpen, setDropdownOpen] = React.useState(false);
|
|
1267
1257
|
|
|
1268
|
-
// Export/import state (#42)
|
|
1269
1258
|
const [transferBusy, setTransferBusy] = React.useState(false);
|
|
1270
1259
|
const [importDoc, setImportDoc] = React.useState(null);
|
|
1271
1260
|
const [importSummary, setImportSummary] = React.useState(null);
|
|
1272
1261
|
const [importStrategy, setImportStrategy] = React.useState('skip');
|
|
1273
1262
|
const importFileRef = React.useRef(null);
|
|
1274
1263
|
|
|
1275
|
-
// Manual / Edit modal state
|
|
1276
1264
|
const [manualModalOpen, setManualModalOpen] = React.useState(false);
|
|
1277
1265
|
const [formId, setFormId] = React.useState(null);
|
|
1278
1266
|
const [formTitle, setFormTitle] = React.useState('');
|
|
@@ -1289,6 +1277,7 @@ window.__ModuleLoader__.load({
|
|
|
1289
1277
|
const [modalTab, setModalTab] = React.useState('params');
|
|
1290
1278
|
const [taskHistory, setTaskHistory] = React.useState([]);
|
|
1291
1279
|
const [historyLoading, setHistoryLoading] = React.useState(false);
|
|
1280
|
+
const [retryingDeliveryId, setRetryingDeliveryId] = React.useState(null);
|
|
1292
1281
|
const [formNotifyTelegram, setFormNotifyTelegram] = React.useState(false);
|
|
1293
1282
|
const [formChannels, setFormChannels] = React.useState([]);
|
|
1294
1283
|
const [formTemplate, setFormTemplate] = React.useState('');
|
|
@@ -1414,8 +1403,6 @@ window.__ModuleLoader__.load({
|
|
|
1414
1403
|
setManualModalOpen(false);
|
|
1415
1404
|
};
|
|
1416
1405
|
|
|
1417
|
-
// Live execution indicator (#35): a light 1s tick only while some task
|
|
1418
|
-
// is actually running.
|
|
1419
1406
|
const tasksRef = React.useRef([]);
|
|
1420
1407
|
const [nowTick, setNowTick] = React.useState(0);
|
|
1421
1408
|
React.useEffect(() => {
|
|
@@ -1428,7 +1415,6 @@ window.__ModuleLoader__.load({
|
|
|
1428
1415
|
return () => clearInterval(tick);
|
|
1429
1416
|
}, []);
|
|
1430
1417
|
|
|
1431
|
-
// Settings modal state
|
|
1432
1418
|
const [settingsModalOpen, setSettingsModalOpen] = React.useState(false);
|
|
1433
1419
|
const [settingsBotToken, setSettingsBotToken] = React.useState('');
|
|
1434
1420
|
const [settingsChatId, setSettingsChatId] = React.useState('');
|
|
@@ -1441,12 +1427,10 @@ window.__ModuleLoader__.load({
|
|
|
1441
1427
|
const [settingsHasCustomBotToken, setSettingsHasCustomBotToken] = React.useState(false);
|
|
1442
1428
|
const [testStatus, setTestStatus] = React.useState(null);
|
|
1443
1429
|
|
|
1444
|
-
// DSH dialog state
|
|
1445
1430
|
const [dshModalOpen, setDshModalOpen] = React.useState(false);
|
|
1446
1431
|
const [dshPrompt, setDshPrompt] = React.useState('');
|
|
1447
1432
|
const [dshLoading, setDshLoading] = React.useState(false);
|
|
1448
1433
|
|
|
1449
|
-
// Models list state
|
|
1450
1434
|
const [providers, setProviders] = React.useState([]);
|
|
1451
1435
|
const [models, setModels] = React.useState([]);
|
|
1452
1436
|
const [defaultModelInfo, setDefaultModelInfo] = React.useState(null);
|
|
@@ -1593,7 +1577,6 @@ window.__ModuleLoader__.load({
|
|
|
1593
1577
|
.then(res => {
|
|
1594
1578
|
if (isDestroyed || pollGen !== fetchGenRef.current) return null;
|
|
1595
1579
|
if (res.status === 304) {
|
|
1596
|
-
// Not modified: skip parsing JSON
|
|
1597
1580
|
return null;
|
|
1598
1581
|
}
|
|
1599
1582
|
const etag = res.headers.get('ETag');
|
|
@@ -1614,13 +1597,11 @@ window.__ModuleLoader__.load({
|
|
|
1614
1597
|
.finally(() => {
|
|
1615
1598
|
if (pollTimer) clearTimeout(pollTimer);
|
|
1616
1599
|
if (isDestroyed) return;
|
|
1617
|
-
// Adaptive interval: 30s when tab is hidden, 8s when active (#134)
|
|
1618
1600
|
const intervalMs = (typeof document !== 'undefined' && document.hidden) ? 30000 : 8000;
|
|
1619
1601
|
timerId = setTimeout(doPoll, intervalMs);
|
|
1620
1602
|
});
|
|
1621
1603
|
};
|
|
1622
1604
|
|
|
1623
|
-
// Trigger immediate fetch when visibility returns
|
|
1624
1605
|
const onVisibilityChange = () => {
|
|
1625
1606
|
if (typeof document !== 'undefined' && !document.hidden) {
|
|
1626
1607
|
if (timerId) clearTimeout(timerId);
|
|
@@ -1666,7 +1647,6 @@ window.__ModuleLoader__.load({
|
|
|
1666
1647
|
try {
|
|
1667
1648
|
data = await res.json();
|
|
1668
1649
|
} catch (jsonErr) {
|
|
1669
|
-
// Re-throw if body reading aborted/timed out (#242)
|
|
1670
1650
|
if (jsonErr && (jsonErr.name === 'AbortError' || controller?.signal.aborted)) {
|
|
1671
1651
|
throw jsonErr;
|
|
1672
1652
|
}
|
|
@@ -1781,8 +1761,6 @@ window.__ModuleLoader__.load({
|
|
|
1781
1761
|
return;
|
|
1782
1762
|
}
|
|
1783
1763
|
await fetchTasks();
|
|
1784
|
-
// A one-shot copy keeps its original schedule string, so warn instead
|
|
1785
|
-
// of letting the user resume a task pointing at a past moment.
|
|
1786
1764
|
alert(task.oneShot ? T_KEY(t, 'duplicate.oneShotHint') : T_KEY(t, 'duplicate.done'));
|
|
1787
1765
|
} catch (err) {
|
|
1788
1766
|
alert(T_KEY(t, 'duplicate.failed', { error: err.message }));
|
|
@@ -2068,7 +2046,6 @@ window.__ModuleLoader__.load({
|
|
|
2068
2046
|
setFormPythonPath('');
|
|
2069
2047
|
setFormSkillName('');
|
|
2070
2048
|
setFormWorkflowName('');
|
|
2071
|
-
// Reset before the preset branch, so a recipe keeps what it sets.
|
|
2072
2049
|
setFormFallbackModel('');
|
|
2073
2050
|
setFormSilentRule('');
|
|
2074
2051
|
setFormInspectOnFailure(false);
|
|
@@ -2076,7 +2053,6 @@ window.__ModuleLoader__.load({
|
|
|
2076
2053
|
setFormTitle(preset.title || '');
|
|
2077
2054
|
setFormSchedule(preset.schedule || '');
|
|
2078
2055
|
setFormPrompt(preset.prompt || '');
|
|
2079
|
-
// #48: a recipe preset also carries its runtime and delivery shape.
|
|
2080
2056
|
if (preset.type) setFormType(preset.type);
|
|
2081
2057
|
if (Array.isArray(preset.channels) && preset.channels.length) setFormChannels(preset.channels.filter((c) => DELIVERY_CHANNELS.includes(c)));
|
|
2082
2058
|
if (preset.silentRule !== undefined) setFormSilentRule(preset.silentRule || '');
|
|
@@ -2150,6 +2126,26 @@ window.__ModuleLoader__.load({
|
|
|
2150
2126
|
setManualModalOpen(true);
|
|
2151
2127
|
};
|
|
2152
2128
|
|
|
2129
|
+
const handleRetryDelivery = async (taskId, runId) => {
|
|
2130
|
+
setRetryingDeliveryId(runId);
|
|
2131
|
+
try {
|
|
2132
|
+
const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/retry-delivery', {
|
|
2133
|
+
method: 'POST',
|
|
2134
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2135
|
+
body: JSON.stringify({ runId }),
|
|
2136
|
+
});
|
|
2137
|
+
if (data && data.ok) {
|
|
2138
|
+
await loadTaskHistory(taskId);
|
|
2139
|
+
} else {
|
|
2140
|
+
alert(data?.error || 'Failed to retry delivery');
|
|
2141
|
+
}
|
|
2142
|
+
} catch (e) {
|
|
2143
|
+
alert(e.message || 'Error retrying delivery');
|
|
2144
|
+
} finally {
|
|
2145
|
+
setRetryingDeliveryId(null);
|
|
2146
|
+
}
|
|
2147
|
+
};
|
|
2148
|
+
|
|
2153
2149
|
const loadTaskHistory = async (taskId) => {
|
|
2154
2150
|
setHistoryLoading(true);
|
|
2155
2151
|
try {
|
|
@@ -2293,8 +2289,6 @@ window.__ModuleLoader__.load({
|
|
|
2293
2289
|
skillName: formSkillName.trim(),
|
|
2294
2290
|
workflowName: formWorkflowName.trim(),
|
|
2295
2291
|
};
|
|
2296
|
-
// Cross-origin hardening: every code-executing type needs the explicit
|
|
2297
|
-
// confirm header, which forged cross-site posts cannot attach (#86)
|
|
2298
2292
|
const headers = { 'Content-Type': 'application/json' };
|
|
2299
2293
|
const hasCodeExec = CODE_FORM_TYPES.includes(formType) ||
|
|
2300
2294
|
Boolean(payload.command && payload.command.trim()) ||
|
|
@@ -2391,7 +2385,6 @@ window.__ModuleLoader__.load({
|
|
|
2391
2385
|
React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: () => openManualModal(null) }, T_KEY(t, 'actions.setupManually'))
|
|
2392
2386
|
)
|
|
2393
2387
|
),
|
|
2394
|
-
// #42: export/import as a small pair of icon buttons.
|
|
2395
2388
|
React.createElement('button', {
|
|
2396
2389
|
type: 'button',
|
|
2397
2390
|
className: 'dsh-cron-icon-btn',
|
|
@@ -2460,7 +2453,6 @@ window.__ModuleLoader__.load({
|
|
|
2460
2453
|
: T_KEY(t, 'tabs.completed'))
|
|
2461
2454
|
)
|
|
2462
2455
|
),
|
|
2463
|
-
// #40: facet filters over the loaded list.
|
|
2464
2456
|
React.createElement('div', { className: 'dsh-cron-filters' },
|
|
2465
2457
|
React.createElement('button', {
|
|
2466
2458
|
type: 'button',
|
|
@@ -2692,7 +2684,6 @@ window.__ModuleLoader__.load({
|
|
|
2692
2684
|
)
|
|
2693
2685
|
),
|
|
2694
2686
|
|
|
2695
|
-
// 1. Manual creation / Edit modal
|
|
2696
2687
|
manualModalOpen && React.createElement('div', {
|
|
2697
2688
|
className: 'dsh-cron-modal-overlay',
|
|
2698
2689
|
onClick: (e) => {
|
|
@@ -2753,8 +2744,39 @@ window.__ModuleLoader__.load({
|
|
|
2753
2744
|
React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary, #888)', fontSize: '11.5px' } }, (run.durationMs || 0) + ' ms')
|
|
2754
2745
|
)
|
|
2755
2746
|
),
|
|
2756
|
-
|
|
2757
|
-
|
|
2747
|
+
run.stoppedReason && React.createElement('div', {
|
|
2748
|
+
className: 'dsh-cron-hint',
|
|
2749
|
+
style: { display: 'flex', gap: '6px', alignItems: 'center', marginTop: '6px' }
|
|
2750
|
+
},
|
|
2751
|
+
React.createElement('span', { className: 'dsh-cron-badge-ref' }, T_KEY(t, 'history.stoppedReasonTitle')),
|
|
2752
|
+
React.createElement('span', null, run.stoppedReason)
|
|
2753
|
+
),
|
|
2754
|
+
Array.isArray(run.stages) && run.stages.length > 0 && React.createElement('div', {
|
|
2755
|
+
className: 'dsh-cron-stages-flow',
|
|
2756
|
+
style: { display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '6px' }
|
|
2757
|
+
},
|
|
2758
|
+
run.stages.map((st) => React.createElement('span', {
|
|
2759
|
+
key: st.name,
|
|
2760
|
+
className: 'dsh-cron-stage-pill',
|
|
2761
|
+
'data-status': st.status || 'unknown',
|
|
2762
|
+
title: st.error ? (st.name + ': ' + st.error) : (st.name + (st.durationMs !== undefined ? ' (' + st.durationMs + 'ms)' : ''))
|
|
2763
|
+
}, (T_KEY(t, 'history.stage_' + st.name) || st.name) + (st.durationMs !== undefined ? ' ' + st.durationMs + 'ms' : '')))
|
|
2764
|
+
),
|
|
2765
|
+
run.delivery && React.createElement('div', {
|
|
2766
|
+
style: { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '6px' }
|
|
2767
|
+
},
|
|
2768
|
+
React.createElement('span', {
|
|
2769
|
+
className: 'dsh-cron-badge-ref',
|
|
2770
|
+
'data-status': run.delivery.status
|
|
2771
|
+
}, 'Delivery: ' + run.delivery.status),
|
|
2772
|
+
(run.delivery.status === 'failed' || run.delivery.status === 'partial') && React.createElement('button', {
|
|
2773
|
+
type: 'button',
|
|
2774
|
+
className: 'dsh-cron-btn-secondary',
|
|
2775
|
+
style: { padding: '2px 8px', fontSize: '11px' },
|
|
2776
|
+
disabled: retryingDeliveryId === run.id,
|
|
2777
|
+
onClick: () => handleRetryDelivery(formId, run.id)
|
|
2778
|
+
}, retryingDeliveryId === run.id ? T_KEY(t, 'history.retryingDelivery') : T_KEY(t, 'history.retryDelivery'))
|
|
2779
|
+
),
|
|
2758
2780
|
run.diagnosis && React.createElement('div', {
|
|
2759
2781
|
className: 'dsh-cron-status-banner',
|
|
2760
2782
|
'data-type': 'info',
|
|
@@ -2774,7 +2796,6 @@ window.__ModuleLoader__.load({
|
|
|
2774
2796
|
}, T_KEY(t, 'history.applySuggestion'))
|
|
2775
2797
|
)
|
|
2776
2798
|
),
|
|
2777
|
-
// #44: a run suppressed by its silent rule says why.
|
|
2778
2799
|
run.silentSkip && React.createElement('div', {
|
|
2779
2800
|
className: 'dsh-cron-hint',
|
|
2780
2801
|
style: { display: 'flex', gap: '6px', alignItems: 'center' }
|
|
@@ -3018,7 +3039,6 @@ window.__ModuleLoader__.load({
|
|
|
3018
3039
|
models.map((m) => React.createElement('option', { key: m.id, value: m.id }, m.name || m.id))
|
|
3019
3040
|
)
|
|
3020
3041
|
),
|
|
3021
|
-
// #45: one retry on a stronger model when the cheap one fails.
|
|
3022
3042
|
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
3023
3043
|
React.createElement('label', null, T_KEY(t, 'form.fallbackModelLabel')),
|
|
3024
3044
|
React.createElement('input', {
|
|
@@ -3029,7 +3049,6 @@ window.__ModuleLoader__.load({
|
|
|
3029
3049
|
}),
|
|
3030
3050
|
React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.fallbackModelHint'))
|
|
3031
3051
|
),
|
|
3032
|
-
// #43: diagnose failures with a model.
|
|
3033
3052
|
React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginTop: '4px' } },
|
|
3034
3053
|
React.createElement('input', {
|
|
3035
3054
|
type: 'checkbox',
|
|
@@ -3261,7 +3280,6 @@ window.__ModuleLoader__.load({
|
|
|
3261
3280
|
}),
|
|
3262
3281
|
React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.templateHint'))
|
|
3263
3282
|
),
|
|
3264
|
-
// #44: output-producing runtimes may stay silent by rule.
|
|
3265
3283
|
!AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-group', style: { marginTop: '12px', marginBottom: 0 } },
|
|
3266
3284
|
React.createElement('label', null, T_KEY(t, 'form.silentRuleLabel')),
|
|
3267
3285
|
React.createElement('input', {
|
|
@@ -3274,7 +3292,6 @@ window.__ModuleLoader__.load({
|
|
|
3274
3292
|
)
|
|
3275
3293
|
),
|
|
3276
3294
|
|
|
3277
|
-
// Reliability, Heartbeat & Self-Healing section
|
|
3278
3295
|
React.createElement('div', {
|
|
3279
3296
|
style: {
|
|
3280
3297
|
marginTop: '16px',
|
|
@@ -3409,7 +3426,6 @@ window.__ModuleLoader__.load({
|
|
|
3409
3426
|
),
|
|
3410
3427
|
|
|
3411
3428
|
|
|
3412
|
-
// Dry Run simulation modal
|
|
3413
3429
|
dryRunResult && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setDryRunResult(null) },
|
|
3414
3430
|
React.createElement('div', {
|
|
3415
3431
|
className: 'dsh-cron-modal',
|
|
@@ -3456,7 +3472,6 @@ window.__ModuleLoader__.load({
|
|
|
3456
3472
|
)
|
|
3457
3473
|
),
|
|
3458
3474
|
|
|
3459
|
-
// Archive runs modal
|
|
3460
3475
|
archiveModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setArchiveModalOpen(false) },
|
|
3461
3476
|
React.createElement('div', {
|
|
3462
3477
|
className: 'dsh-cron-modal',
|
|
@@ -3524,7 +3539,6 @@ window.__ModuleLoader__.load({
|
|
|
3524
3539
|
)
|
|
3525
3540
|
),
|
|
3526
3541
|
|
|
3527
|
-
// 2. Settings modal
|
|
3528
3542
|
settingsModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setSettingsModalOpen(false) },
|
|
3529
3543
|
React.createElement('div', { className: 'dsh-cron-modal dsh-cron-modal-scroll', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
|
|
3530
3544
|
React.createElement('div', { className: 'dsh-cron-modal-head', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '18px' } },
|
|
@@ -3611,7 +3625,6 @@ window.__ModuleLoader__.load({
|
|
|
3611
3625
|
)
|
|
3612
3626
|
),
|
|
3613
3627
|
|
|
3614
|
-
// 3. Import modal (#42) — summary first, then an explicit strategy.
|
|
3615
3628
|
importDoc && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !transferBusy && setImportDoc(null) },
|
|
3616
3629
|
React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
|
|
3617
3630
|
React.createElement('div', { className: 'dsh-cron-modal-title' }, T_KEY(t, 'transfer.importTitleModal')),
|
|
@@ -3654,7 +3667,6 @@ window.__ModuleLoader__.load({
|
|
|
3654
3667
|
)
|
|
3655
3668
|
),
|
|
3656
3669
|
|
|
3657
|
-
// 4. "Create with DSH" modal
|
|
3658
3670
|
dshModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !dshLoading && setDshModalOpen(false) },
|
|
3659
3671
|
React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
|
|
3660
3672
|
React.createElement('div', { className: 'dsh-cron-modal-title' }, T_KEY(t, 'dsh.modalTitle')),
|
|
@@ -3717,7 +3729,6 @@ window.__ModuleLoader__.load({
|
|
|
3717
3729
|
const [hasDefault, setHasDefault] = React.useState(false);
|
|
3718
3730
|
const [saved, setSaved] = React.useState(false);
|
|
3719
3731
|
const [testInfo, setTestInfo] = React.useState(null);
|
|
3720
|
-
// Collapsed by default, head is the toggle (canonical card contract, #100)
|
|
3721
3732
|
const [cardOpen, setCardOpen] = React.useState(false);
|
|
3722
3733
|
const [updateState, setUpdateState] = React.useState({
|
|
3723
3734
|
checking: false,
|
|
@@ -3777,8 +3788,6 @@ window.__ModuleLoader__.load({
|
|
|
3777
3788
|
}
|
|
3778
3789
|
};
|
|
3779
3790
|
|
|
3780
|
-
// Settings snapshot status (#102): never render phantom inputs before
|
|
3781
|
-
// the snapshot arrives; offer a retry when it is unavailable.
|
|
3782
3791
|
const [loadState, setLoadState] = React.useState('loading');
|
|
3783
3792
|
const [reloadTick, setReloadTick] = React.useState(0);
|
|
3784
3793
|
|
|
@@ -3887,8 +3896,6 @@ window.__ModuleLoader__.load({
|
|
|
3887
3896
|
);
|
|
3888
3897
|
}
|
|
3889
3898
|
|
|
3890
|
-
// Row seat (plugins.row.config): the host page draws title/icon/crumb and the
|
|
3891
|
-
// padding, so the summary is a one-liner and the page drops our card chrome.
|
|
3892
3899
|
if (props && props.view === 'summary') {
|
|
3893
3900
|
return React.createElement('span', { className: 'dsh-cron-card-desc' }, T_KEY(t, 'settings.cardDesc'));
|
|
3894
3901
|
}
|
|
@@ -4116,8 +4123,6 @@ window.__ModuleLoader__.load({
|
|
|
4116
4123
|
const row = document.createElement('button');
|
|
4117
4124
|
row.type = 'button';
|
|
4118
4125
|
row.className = 'dsh-cron-sidebar-job';
|
|
4119
|
-
// A distinct attribute: the panel uses data-task-id, and the highlight
|
|
4120
|
-
// lookup must never pick a sidebar row instead of a task card.
|
|
4121
4126
|
row.setAttribute('data-sidebar-task-id', task.id);
|
|
4122
4127
|
row.setAttribute('aria-label', task.title || task.id);
|
|
4123
4128
|
|
|
@@ -4218,7 +4223,6 @@ window.__ModuleLoader__.load({
|
|
|
4218
4223
|
entry.insertAdjacentElement('afterend', buildSidebarJobsRoot(state, label));
|
|
4219
4224
|
} else {
|
|
4220
4225
|
entry.insertAdjacentElement('afterend', state.root);
|
|
4221
|
-
// Re-query the refs: the previous nodes may belong to a detached tree.
|
|
4222
4226
|
state.root = document.querySelector('[data-dsh-part="sidebar-jobs"]') || state.root;
|
|
4223
4227
|
state.listEl = state.root.querySelector('.dsh-cron-sidebar-jobs-list');
|
|
4224
4228
|
state.headEl = state.root.querySelector('.dsh-cron-sidebar-jobs-head');
|
|
@@ -4335,8 +4339,6 @@ window.__ModuleLoader__.load({
|
|
|
4335
4339
|
|
|
4336
4340
|
const applyActive = () => {
|
|
4337
4341
|
if (toggle.isOpen()) {
|
|
4338
|
-
// Repair a dropped stylesheet before the panel paints, so opening the
|
|
4339
|
-
// screen never shows an unstyled page (see startStyleSelfHeal).
|
|
4340
4342
|
ensureStyles();
|
|
4341
4343
|
document.documentElement.setAttribute(ACTIVE_ATTR, '');
|
|
4342
4344
|
document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL }));
|
|
@@ -4396,14 +4398,12 @@ window.__ModuleLoader__.load({
|
|
|
4396
4398
|
function apply(ctx) {
|
|
4397
4399
|
const stopStyleSelfHeal = startStyleSelfHeal();
|
|
4398
4400
|
|
|
4399
|
-
// Register the English canonical strings; rebinding the translator lets
|
|
4400
|
-
// the translation plugin drive the active language at runtime (#87).
|
|
4401
4401
|
try {
|
|
4402
4402
|
if (ctx.locale && typeof ctx.locale.register === 'function') {
|
|
4403
4403
|
ctx.locale.register(NS, STRINGS);
|
|
4404
4404
|
}
|
|
4405
4405
|
} catch (e) {
|
|
4406
|
-
|
|
4406
|
+
void e;
|
|
4407
4407
|
}
|
|
4408
4408
|
try {
|
|
4409
4409
|
if (ctx.locale && typeof ctx.locale.bind === 'function') {
|
|
@@ -4415,16 +4415,9 @@ window.__ModuleLoader__.load({
|
|
|
4415
4415
|
|
|
4416
4416
|
const toggle = createToggle();
|
|
4417
4417
|
|
|
4418
|
-
// The settings card lives ONLY in the plugins settings tab (#102): no
|
|
4419
|
-
// top-level section fallback — the flat sidebar list is a shared core
|
|
4420
|
-
// resource, and the card slot is proven on current builds (#85).
|
|
4421
4418
|
function SettingsCardWithBoundary(props) {
|
|
4422
4419
|
return React.createElement(ErrorBoundary, null, CronSettingsCard(props));
|
|
4423
4420
|
}
|
|
4424
|
-
// List seat (plugins.item): the seat the Plugins page renders as the plugin's own
|
|
4425
|
-
// page with its configuration. The label is a static string on purpose — it is
|
|
4426
|
-
// resolved while the page renders, and a locale lookup there would take the whole
|
|
4427
|
-
// client batch down with it.
|
|
4428
4421
|
const itemOk = registerIntoMount(ctx, 'plugins.item', {
|
|
4429
4422
|
name: 'plugins.item',
|
|
4430
4423
|
id: ROW_ID,
|
|
@@ -4436,8 +4429,6 @@ window.__ModuleLoader__.load({
|
|
|
4436
4429
|
if (!itemOk) {
|
|
4437
4430
|
console.warn('[dsh-cron] plugins.item slot unavailable — plugin page card not registered');
|
|
4438
4431
|
}
|
|
4439
|
-
// Row seat and the legacy settings.plugin.item seat stay as fallbacks
|
|
4440
|
-
// (#102: still no top-level section fallback).
|
|
4441
4432
|
const rowOk = registerIntoMount(ctx, 'plugins.row.config', {
|
|
4442
4433
|
name: 'plugins.row.config',
|
|
4443
4434
|
key: ROW_CONFIG_KEY,
|
package/lib/cron-tool.js
CHANGED
|
@@ -152,6 +152,26 @@ export const cronToolParameters = {
|
|
|
152
152
|
enum: ['default', 'read-only', 'workspace-write', 'full'],
|
|
153
153
|
description: 'Permission preset applied to the task session (only used for agent session tasks: llm, skill, workflow)',
|
|
154
154
|
},
|
|
155
|
+
validators: {
|
|
156
|
+
type: 'array',
|
|
157
|
+
description: 'Declarative post-execution validation checks (json, regex, text, exitCode, file)',
|
|
158
|
+
items: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
properties: {
|
|
161
|
+
type: { type: 'string', enum: ['json', 'regex', 'text', 'exitCode', 'file'] },
|
|
162
|
+
pattern: { type: 'string' },
|
|
163
|
+
text: { type: 'string' },
|
|
164
|
+
negate: { type: 'boolean' },
|
|
165
|
+
statusKey: { type: 'string' },
|
|
166
|
+
expected: { type: 'string' },
|
|
167
|
+
path: { type: 'string' },
|
|
168
|
+
mustExist: { type: 'boolean' },
|
|
169
|
+
minBytes: { type: 'number' },
|
|
170
|
+
target: { type: 'string' },
|
|
171
|
+
},
|
|
172
|
+
additionalProperties: true,
|
|
173
|
+
},
|
|
174
|
+
},
|
|
155
175
|
kanbanMode: {
|
|
156
176
|
type: 'string',
|
|
157
177
|
enum: ['none', 'on_failure', 'always'],
|
package/lib/index.js
CHANGED
|
@@ -165,7 +165,10 @@ export function apply(ctx, rawConfig) {
|
|
|
165
165
|
deliver: (task, runInfo, settings) => deliverRun({
|
|
166
166
|
task,
|
|
167
167
|
runInfo,
|
|
168
|
-
settings
|
|
168
|
+
settings: {
|
|
169
|
+
...settings,
|
|
170
|
+
_webServerPort: ctx.webServer?.port || 3080,
|
|
171
|
+
},
|
|
169
172
|
secrets: {
|
|
170
173
|
botToken: settings.botToken || '',
|
|
171
174
|
resolveSecret: (ref) => resolveCredentialValue(ctx, ref),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateRunResult } from './validators.js';
|
|
1
2
|
import { logger } from './logger.js';
|
|
2
3
|
import { checkAndApplyBurnGuard, checkTaskBudgetLimits } from './burn-guard.js';
|
|
3
4
|
import { resolveTaskForExecution } from './prompt-interpolation.js';
|
|
@@ -327,7 +328,9 @@ export async function executeTask(scheduler, taskId, options = {}) {
|
|
|
327
328
|
const currentRun = scheduler.beginRun(task, taskId, options);
|
|
328
329
|
if (currentRun === null) return null;
|
|
329
330
|
|
|
331
|
+
const preflightStart = Date.now();
|
|
330
332
|
const preflight = await executePreflight(task);
|
|
333
|
+
const preflightDurationMs = Date.now() - preflightStart;
|
|
331
334
|
if (!preflight.ok) {
|
|
332
335
|
logger.info(`[dsh-cron] task "${task.title}" (${taskId}) skipped: ${preflight.reason}`);
|
|
333
336
|
scheduler.running.delete(taskId);
|
|
@@ -337,7 +340,10 @@ export async function executeTask(scheduler, taskId, options = {}) {
|
|
|
337
340
|
|
|
338
341
|
const start = Date.now();
|
|
339
342
|
const outcome = await scheduler.executeWithFallback(task, currentRun.controller.signal, options);
|
|
340
|
-
await scheduler.completeRun(task, taskId, currentRun, outcome, start,
|
|
343
|
+
await scheduler.completeRun(task, taskId, currentRun, outcome, start, {
|
|
344
|
+
...options,
|
|
345
|
+
preflightDurationMs,
|
|
346
|
+
});
|
|
341
347
|
return outcome;
|
|
342
348
|
}
|
|
343
349
|
|
|
@@ -429,7 +435,7 @@ export async function deliverNotifications(task, runInfo, { store, resolveSecret
|
|
|
429
435
|
logger.warn('[dsh-cron] credential resolution failed:', secErr.message);
|
|
430
436
|
}
|
|
431
437
|
}
|
|
432
|
-
if (typeof deliver !== 'function') return;
|
|
438
|
+
if (typeof deliver !== 'function') return { status: 'skipped', delivered: [], failures: [] };
|
|
433
439
|
const result = await deliver(task, runInfo, settings);
|
|
434
440
|
if (result && Array.isArray(result.delivered) && result.delivered.length) {
|
|
435
441
|
logger.info(`[dsh-cron] delivered "${task.title}" to: ${result.delivered.map((d) => d.channel).join(', ')}`);
|
|
@@ -437,8 +443,16 @@ export async function deliverNotifications(task, runInfo, { store, resolveSecret
|
|
|
437
443
|
if (result && Array.isArray(result.failures) && result.failures.length) {
|
|
438
444
|
logger.warn(`[dsh-cron] delivery failures for "${task.title}": ${result.failures.map((f) => `${f.channel}: ${f.error}`).join('; ')}`);
|
|
439
445
|
}
|
|
446
|
+
const delivered = Array.isArray(result?.delivered) ? result.delivered : [];
|
|
447
|
+
const failures = Array.isArray(result?.failures) ? result.failures : [];
|
|
448
|
+
let status = 'skipped';
|
|
449
|
+
if (delivered.length > 0 && failures.length === 0) status = 'delivered';
|
|
450
|
+
else if (delivered.length > 0 && failures.length > 0) status = 'partial';
|
|
451
|
+
else if (delivered.length === 0 && failures.length > 0) status = 'failed';
|
|
452
|
+
return { status, delivered, failures };
|
|
440
453
|
} catch (notifyErr) {
|
|
441
454
|
logger.error('[dsh-cron] notification error:', notifyErr.message);
|
|
455
|
+
return { status: 'failed', delivered: [], failures: [{ channel: 'all', error: notifyErr.message }] };
|
|
442
456
|
}
|
|
443
457
|
}
|
|
444
458
|
|
|
@@ -522,13 +536,41 @@ export function finishRun(scheduler, taskSnapshot, taskId, status, queuedCount,
|
|
|
522
536
|
/**
|
|
523
537
|
* Handle complete run lifecycle: metrics, self-healing, diagnosis, silent rule, storage, delivery, chaining.
|
|
524
538
|
*/
|
|
539
|
+
function computeStoppedReason(status, valRes, error) {
|
|
540
|
+
if (status === 'validation_failed') return `Validation failed: ${valRes?.error || 'Check failed'}`;
|
|
541
|
+
if (status === 'validator_error') return `Validator configuration error: ${valRes?.error || 'Check failed'}`;
|
|
542
|
+
if (status === 'timeout') return 'Execution timed out';
|
|
543
|
+
if (status === 'error') return error ? `Execution error: ${error}` : 'Execution error';
|
|
544
|
+
if (status === 'success') return 'Completed successfully';
|
|
545
|
+
if (status === 'skipped') return error || 'Run skipped';
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
|
|
525
549
|
export async function handleCompleteRun(scheduler, task, taskId, currentRun, outcome, start, options = {}) {
|
|
550
|
+
// Post-execution declarative validation (#316)
|
|
551
|
+
let valRes = { ok: true, skipped: true };
|
|
552
|
+
let valDurationMs = 0;
|
|
553
|
+
if (outcome.status === 'success' && Array.isArray(task.validators) && task.validators.length > 0) {
|
|
554
|
+
const vStart = Date.now();
|
|
555
|
+
valRes = validateRunResult(task, outcome, { cwd: task.cwd });
|
|
556
|
+
valDurationMs = Date.now() - vStart;
|
|
557
|
+
if (!valRes.ok) {
|
|
558
|
+
outcome.status = valRes.status === 'error' ? 'validator_error' : 'validation_failed';
|
|
559
|
+
outcome.error = valRes.error || 'Validation failed';
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
526
563
|
const isSuccess = outcome.status === 'success';
|
|
527
|
-
const isFailure = outcome.status === 'error' || outcome.status === 'timeout';
|
|
564
|
+
const isFailure = outcome.status === 'error' || outcome.status === 'timeout' || outcome.status === 'validation_failed' || outcome.status === 'validator_error';
|
|
565
|
+
const executionMs = Date.now() - start;
|
|
566
|
+
const queueWaitMs = (currentRun && currentRun.startedAt && currentRun.queuedAt) ? (currentRun.startedAt - currentRun.queuedAt) : 0;
|
|
567
|
+
const preflightMs = options.preflightDurationMs || 0;
|
|
568
|
+
|
|
528
569
|
const runInfo = {
|
|
570
|
+
runId: currentRun?.id || ('run_' + Math.random().toString(36).slice(2, 9) + '_' + Date.now()),
|
|
529
571
|
at: start,
|
|
530
572
|
status: outcome.status,
|
|
531
|
-
durationMs:
|
|
573
|
+
durationMs: executionMs,
|
|
532
574
|
output: outcome.output,
|
|
533
575
|
error: outcome.error,
|
|
534
576
|
usage: outcome.usage,
|
|
@@ -536,6 +578,7 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
536
578
|
sessionId: outcome.sessionId,
|
|
537
579
|
model: outcome.model || task.model || '',
|
|
538
580
|
fallback: Boolean(outcome.fallback),
|
|
581
|
+
validation: valRes.skipped ? null : valRes,
|
|
539
582
|
};
|
|
540
583
|
|
|
541
584
|
if (isFailure && task.selfHealingCommand) {
|
|
@@ -573,6 +616,58 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
573
616
|
|
|
574
617
|
const silent = await scheduler.applyModelAssists(task, runInfo);
|
|
575
618
|
|
|
619
|
+
let deliveryResult = { status: 'skipped', delivered: [], failures: [] };
|
|
620
|
+
let delivStart = Date.now();
|
|
621
|
+
if (!silent.skipped) {
|
|
622
|
+
deliveryResult = (await scheduler.deliverNotifications(task, runInfo)) || deliveryResult;
|
|
623
|
+
}
|
|
624
|
+
const delivDurationMs = Date.now() - delivStart;
|
|
625
|
+
|
|
626
|
+
const stages = [
|
|
627
|
+
{
|
|
628
|
+
name: 'queue',
|
|
629
|
+
status: 'success',
|
|
630
|
+
durationMs: queueWaitMs,
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
name: 'preflight',
|
|
634
|
+
status: 'success',
|
|
635
|
+
durationMs: preflightMs,
|
|
636
|
+
error: null,
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
name: 'execution',
|
|
640
|
+
status: (outcome.status === 'error' || outcome.status === 'timeout') ? 'failed' : 'success',
|
|
641
|
+
durationMs: executionMs,
|
|
642
|
+
error: (outcome.status === 'error' || outcome.status === 'timeout') ? (outcome.error || null) : null,
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
name: 'validation',
|
|
646
|
+
status: valRes.skipped ? 'skipped' : (valRes.ok ? 'success' : 'failed'),
|
|
647
|
+
durationMs: valDurationMs,
|
|
648
|
+
error: valRes.ok ? null : (valRes.error || null),
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
name: 'delivery',
|
|
652
|
+
status: deliveryResult?.status || 'skipped',
|
|
653
|
+
durationMs: delivDurationMs,
|
|
654
|
+
error: (deliveryResult?.failures && deliveryResult.failures.length > 0)
|
|
655
|
+
? deliveryResult.failures.map(f => `${f.channel}: ${f.error}`).join('; ')
|
|
656
|
+
: null,
|
|
657
|
+
delivered: deliveryResult?.delivered || [],
|
|
658
|
+
failures: deliveryResult?.failures || [],
|
|
659
|
+
},
|
|
660
|
+
];
|
|
661
|
+
|
|
662
|
+
runInfo.stages = stages;
|
|
663
|
+
runInfo.delivery = deliveryResult;
|
|
664
|
+
runInfo.cost = {
|
|
665
|
+
amount: Number(outcome.costUsd) || 0,
|
|
666
|
+
currency: 'USD',
|
|
667
|
+
kind: (Number(outcome.costUsd) > 0) ? 'exact' : ((task.type === 'llm' || task.type === 'skill' || task.type === 'workflow') ? 'unknown' : 'exact'),
|
|
668
|
+
};
|
|
669
|
+
runInfo.stoppedReason = computeStoppedReason(outcome.status, valRes, outcome.error);
|
|
670
|
+
|
|
576
671
|
try {
|
|
577
672
|
scheduler.store.recordRun(taskId, runInfo);
|
|
578
673
|
} catch (recordErr) {
|
|
@@ -585,7 +680,6 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
|
|
|
585
680
|
const queuedRuns = isCurrentRun ? (finishedRun?.queuedRuns || []) : [];
|
|
586
681
|
const queuedCount = isCurrentRun ? (finishedRun?.queueCount || 0) : 0;
|
|
587
682
|
|
|
588
|
-
if (!silent.skipped) await scheduler.deliverNotifications(task, runInfo);
|
|
589
683
|
scheduler.finishRun(task, taskId, outcome.status, queuedCount, {
|
|
590
684
|
isCurrentRun,
|
|
591
685
|
runId: currentRun?.id,
|
package/lib/scheduler.js
CHANGED
|
@@ -548,7 +548,9 @@ export class TaskScheduler {
|
|
|
548
548
|
|
|
549
549
|
recordSkipped(taskId, reason) {
|
|
550
550
|
this.countRun('skipped');
|
|
551
|
+
const runId = 'run_' + Math.random().toString(36).slice(2, 9) + '_' + Date.now();
|
|
551
552
|
this.store.recordRun(taskId, {
|
|
553
|
+
runId,
|
|
552
554
|
at: Date.now(),
|
|
553
555
|
status: 'skipped',
|
|
554
556
|
durationMs: 0,
|
|
@@ -556,6 +558,16 @@ export class TaskScheduler {
|
|
|
556
558
|
error: null,
|
|
557
559
|
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
558
560
|
costUsd: 0,
|
|
561
|
+
stoppedReason: reason,
|
|
562
|
+
stages: [
|
|
563
|
+
{ name: 'queue', status: 'skipped', durationMs: 0 },
|
|
564
|
+
{ name: 'preflight', status: 'skipped', durationMs: 0, error: reason },
|
|
565
|
+
{ name: 'execution', status: 'skipped', durationMs: 0 },
|
|
566
|
+
{ name: 'validation', status: 'skipped', durationMs: 0 },
|
|
567
|
+
{ name: 'delivery', status: 'skipped', durationMs: 0 },
|
|
568
|
+
],
|
|
569
|
+
cost: { amount: 0, currency: 'USD', kind: 'exact' },
|
|
570
|
+
delivery: { status: 'skipped', delivered: [], failures: [] },
|
|
559
571
|
});
|
|
560
572
|
}
|
|
561
573
|
|
package/lib/settings.js
CHANGED
|
@@ -40,7 +40,7 @@ export const Config = z.object({
|
|
|
40
40
|
barkKey: z.string().default('').description('Bark device key').volatile(),
|
|
41
41
|
pushplusTokenRef: z.string().default('').description('Credential name for the PushPlus token').volatile(),
|
|
42
42
|
pushplusUrl: z.string().default('https://www.pushplus.plus/send').description('PushPlus send endpoint (override for a self-hosted proxy)').volatile(),
|
|
43
|
-
ttsBaseUrl: z.string().default('
|
|
43
|
+
ttsBaseUrl: z.string().default('').description('Base URL of the harness used to reach the dsh-tts speak route (empty = auto-resolved from webServer port or http://127.0.0.1:3080)').volatile(),
|
|
44
44
|
giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues').volatile(),
|
|
45
45
|
giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues').volatile(),
|
|
46
46
|
giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token').volatile(),
|
package/lib/store.js
CHANGED
|
@@ -689,7 +689,7 @@ export class TaskStore {
|
|
|
689
689
|
const prevRuns = this.history.get(id) || [];
|
|
690
690
|
const runs = [
|
|
691
691
|
{
|
|
692
|
-
id: 'run_' + Math.random().toString(36).slice(2, 9),
|
|
692
|
+
id: runInfo.runId || runInfo.id || ('run_' + Math.random().toString(36).slice(2, 9)),
|
|
693
693
|
at: task.lastRunAt,
|
|
694
694
|
status: task.lastStatus,
|
|
695
695
|
durationMs: task.lastDurationMs,
|
|
@@ -697,6 +697,15 @@ export class TaskStore {
|
|
|
697
697
|
error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
|
|
698
698
|
usage,
|
|
699
699
|
costUsd,
|
|
700
|
+
stages: Array.isArray(runInfo.stages) ? runInfo.stages : null,
|
|
701
|
+
stoppedReason: runInfo.stoppedReason || null,
|
|
702
|
+
cost: runInfo.cost || {
|
|
703
|
+
amount: costUsd,
|
|
704
|
+
currency: 'USD',
|
|
705
|
+
kind: costUsd > 0 ? 'exact' : ((task.type === 'llm' || task.type === 'skill' || task.type === 'workflow') ? 'unknown' : 'exact')
|
|
706
|
+
},
|
|
707
|
+
delivery: runInfo.delivery || null,
|
|
708
|
+
validation: runInfo.validation || null,
|
|
700
709
|
sessionId: runInfo.sessionId || null,
|
|
701
710
|
// #45: which model produced the run, and whether it only finished thanks
|
|
702
711
|
// to the configured fallback.
|
package/lib/task-create.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeValidators } from './validators.js';
|
|
1
2
|
import { normalizeTaskType, EXTERNAL_RUNTIMES } from './runtimes.js';
|
|
2
3
|
import { validateTaskType } from './task-transfer.js';
|
|
3
4
|
import { parseScheduleExpression } from './scheduler.js';
|
|
@@ -72,6 +73,7 @@ export function executeCreateTask(store, scheduler, args) {
|
|
|
72
73
|
costLimitUsd: Number(args.costLimitUsd) > 0 ? Number(args.costLimitUsd) : undefined,
|
|
73
74
|
dailyCostLimitUsd: Number(args.dailyCostLimitUsd) > 0 ? Number(args.dailyCostLimitUsd) : undefined,
|
|
74
75
|
tokenLimit: Number(args.tokenLimit) > 0 ? Number(args.tokenLimit) : undefined,
|
|
76
|
+
validators: Array.isArray(args.validators) ? sanitizeValidators(args.validators) : undefined,
|
|
75
77
|
};
|
|
76
78
|
// Issue #310 / GH #5: strip any undefined properties before store.set()
|
|
77
79
|
for (const k of Object.keys(taskData)) {
|
package/lib/task-patch.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeValidators } from './validators.js';
|
|
1
2
|
/**
|
|
2
3
|
* Partial task updates, shared by the HTTP PATCH route and the cron tool (action: 'update')
|
|
3
4
|
* tool (#49).
|
|
@@ -86,6 +87,9 @@ export function buildTaskPatch(current, body) {
|
|
|
86
87
|
return { ok: false, error: err.message };
|
|
87
88
|
}
|
|
88
89
|
}
|
|
90
|
+
if (patch.validators !== undefined) {
|
|
91
|
+
patch.validators = Array.isArray(patch.validators) ? sanitizeValidators(patch.validators) : [];
|
|
92
|
+
}
|
|
89
93
|
if (patch.costLimitUsd !== undefined) {
|
|
90
94
|
patch.costLimitUsd = patch.costLimitUsd === null ? null : (Number(patch.costLimitUsd) > 0 ? Number(patch.costLimitUsd) : null);
|
|
91
95
|
}
|
package/lib/task-transfer.js
CHANGED
package/lib/telegram.js
CHANGED
|
@@ -210,7 +210,7 @@ export function shouldNotifyTask(task, runInfo, globalSettings = {}) {
|
|
|
210
210
|
const isEnabled = task.notifyTelegram ?? globalSettings.notifyTelegram ?? false;
|
|
211
211
|
if (!isEnabled) return false;
|
|
212
212
|
|
|
213
|
-
const failed = runInfo.status === 'error' || runInfo.status === 'timeout' || runInfo.status === 'missed';
|
|
213
|
+
const failed = runInfo.status === 'error' || runInfo.status === 'timeout' || runInfo.status === 'missed' || runInfo.status === 'validation_failed' || runInfo.status === 'validator_error';
|
|
214
214
|
const onlyOnFail = task.onlyOnFailure ?? globalSettings.onlyOnFailure ?? false;
|
|
215
215
|
if (onlyOnFail && !failed) {
|
|
216
216
|
return false;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const VALIDATOR_TYPES = ['json', 'regex', 'text', 'exitCode', 'file'];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validates run output/result against declarative task validators.
|
|
8
|
+
* Pure and deterministic: never uses eval or dynamic code execution (#316).
|
|
9
|
+
*
|
|
10
|
+
* @param {object} task Task record (may have task.validators)
|
|
11
|
+
* @param {object} runInfo Run result ({ output, stdout, stderr, exitCode, error, status })
|
|
12
|
+
* @param {object} [options] Context options ({ cwd, workspaceDir })
|
|
13
|
+
* @returns {{ ok: boolean, status: 'passed' | 'failed' | 'error' | 'skipped', error?: string, details: Array<{ type: string, passed: boolean, message: string }> }}
|
|
14
|
+
*/
|
|
15
|
+
export function validateRunResult(task, runInfo, options = {}) {
|
|
16
|
+
const validators = Array.isArray(task?.validators) ? task.validators : [];
|
|
17
|
+
if (validators.length === 0) {
|
|
18
|
+
return { ok: true, status: 'skipped', details: [] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const details = [];
|
|
22
|
+
const baseDir = options.workspaceDir || options.cwd || task?.cwd || process.cwd();
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < validators.length; i++) {
|
|
25
|
+
const v = validators[i];
|
|
26
|
+
if (!v || typeof v !== 'object') continue;
|
|
27
|
+
const type = String(v.type || '').trim().toLowerCase();
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
if (type === 'exitCode') {
|
|
31
|
+
const expected = Number(v.expected !== undefined ? v.expected : 0);
|
|
32
|
+
const actual = Number(runInfo.exitCode !== undefined ? runInfo.exitCode : 0);
|
|
33
|
+
const passed = actual === expected;
|
|
34
|
+
const message = passed
|
|
35
|
+
? `Exit code matches expected (${expected})`
|
|
36
|
+
: `Expected exit code ${expected}, got ${actual}`;
|
|
37
|
+
details.push({ type: 'exitCode', passed, message });
|
|
38
|
+
if (!passed) {
|
|
39
|
+
return { ok: false, status: 'failed', error: message, details };
|
|
40
|
+
}
|
|
41
|
+
} else if (type === 'json') {
|
|
42
|
+
const text = String(runInfo.output || runInfo.stdout || '').trim();
|
|
43
|
+
let parsed = null;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(text);
|
|
46
|
+
} catch (jsonErr) {
|
|
47
|
+
const message = `Output is not valid JSON: ${jsonErr.message}`;
|
|
48
|
+
details.push({ type: 'json', passed: false, message });
|
|
49
|
+
return { ok: false, status: 'failed', error: message, details };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (v.statusKey) {
|
|
53
|
+
const val = parsed[v.statusKey];
|
|
54
|
+
const expected = v.expected;
|
|
55
|
+
const passed = expected !== undefined ? String(val) === String(expected) : Boolean(val);
|
|
56
|
+
const message = passed
|
|
57
|
+
? `JSON field "${v.statusKey}" matches expected (${String(expected)})`
|
|
58
|
+
: `JSON field "${v.statusKey}" value (${JSON.stringify(val)}) does not match expected (${JSON.stringify(expected)})`;
|
|
59
|
+
details.push({ type: 'json', passed, message });
|
|
60
|
+
if (!passed) {
|
|
61
|
+
return { ok: false, status: 'failed', error: message, details };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (v.path) {
|
|
66
|
+
const parts = String(v.path).split('.');
|
|
67
|
+
let cur = parsed;
|
|
68
|
+
let found = true;
|
|
69
|
+
for (const p of parts) {
|
|
70
|
+
if (cur && typeof cur === 'object' && p in cur) {
|
|
71
|
+
cur = cur[p];
|
|
72
|
+
} else {
|
|
73
|
+
found = false;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (v.mustExist && !found) {
|
|
78
|
+
const message = `JSON path "${v.path}" not found in output`;
|
|
79
|
+
details.push({ type: 'json', passed: false, message });
|
|
80
|
+
return { ok: false, status: 'failed', error: message, details };
|
|
81
|
+
}
|
|
82
|
+
if (v.minCount !== undefined && Array.isArray(cur) && cur.length < Number(v.minCount)) {
|
|
83
|
+
const message = `JSON path "${v.path}" array length (${cur.length}) is less than minCount (${v.minCount})`;
|
|
84
|
+
details.push({ type: 'json', passed: false, message });
|
|
85
|
+
return { ok: false, status: 'failed', error: message, details };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
details.push({ type: 'json', passed: true, message: 'Valid JSON output' });
|
|
90
|
+
} else if (type === 'text') {
|
|
91
|
+
const targetStr = getTargetText(runInfo, v.target);
|
|
92
|
+
const sub = String(v.text || '');
|
|
93
|
+
const contains = targetStr.includes(sub);
|
|
94
|
+
const passed = v.negate ? !contains : contains;
|
|
95
|
+
const message = passed
|
|
96
|
+
? `Text check passed for "${sub}"`
|
|
97
|
+
: (v.negate ? `Output unexpectedly contains forbidden text "${sub}"` : `Output does not contain required text "${sub}"`);
|
|
98
|
+
details.push({ type: 'text', passed, message });
|
|
99
|
+
if (!passed) {
|
|
100
|
+
return { ok: false, status: 'failed', error: message, details };
|
|
101
|
+
}
|
|
102
|
+
} else if (type === 'regex') {
|
|
103
|
+
const targetStr = getTargetText(runInfo, v.target);
|
|
104
|
+
const pattern = String(v.pattern || '');
|
|
105
|
+
let reg;
|
|
106
|
+
try {
|
|
107
|
+
reg = new RegExp(pattern, v.flags || 'i');
|
|
108
|
+
} catch (regErr) {
|
|
109
|
+
const message = `Invalid regex pattern "${pattern}": ${regErr.message}`;
|
|
110
|
+
details.push({ type: 'regex', passed: false, message });
|
|
111
|
+
return { ok: false, status: 'error', error: message, details };
|
|
112
|
+
}
|
|
113
|
+
const matches = reg.test(targetStr);
|
|
114
|
+
const passed = v.negate ? !matches : matches;
|
|
115
|
+
const message = passed
|
|
116
|
+
? `Regex match passed for /${pattern}/`
|
|
117
|
+
: (v.negate ? `Output matches forbidden pattern /${pattern}/` : `Output does not match required pattern /${pattern}/`);
|
|
118
|
+
details.push({ type: 'regex', passed, message });
|
|
119
|
+
if (!passed) {
|
|
120
|
+
return { ok: false, status: 'failed', error: message, details };
|
|
121
|
+
}
|
|
122
|
+
} else if (type === 'file') {
|
|
123
|
+
const relPath = String(v.path || '').trim();
|
|
124
|
+
if (!relPath) {
|
|
125
|
+
details.push({ type: 'file', passed: false, message: 'File path cannot be empty' });
|
|
126
|
+
return { ok: false, status: 'failed', error: 'File path cannot be empty', details };
|
|
127
|
+
}
|
|
128
|
+
// Guard against directory traversal escaping baseDir
|
|
129
|
+
const resolvedPath = path.resolve(baseDir, relPath);
|
|
130
|
+
const resolvedBase = path.resolve(baseDir);
|
|
131
|
+
if (!resolvedPath.startsWith(resolvedBase)) {
|
|
132
|
+
const message = `File path "${relPath}" escapes allowed directory boundary`;
|
|
133
|
+
details.push({ type: 'file', passed: false, message });
|
|
134
|
+
return { ok: false, status: 'failed', error: message, details };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const exists = fs.existsSync(resolvedPath);
|
|
138
|
+
if (v.mustExist !== false && !exists) {
|
|
139
|
+
const message = `Required file not found: "${relPath}"`;
|
|
140
|
+
details.push({ type: 'file', passed: false, message });
|
|
141
|
+
return { ok: false, status: 'failed', error: message, details };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (exists && v.minBytes !== undefined) {
|
|
145
|
+
const stats = fs.statSync(resolvedPath);
|
|
146
|
+
if (stats.size < Number(v.minBytes)) {
|
|
147
|
+
const message = `File "${relPath}" size (${stats.size} bytes) is below minimum (${v.minBytes} bytes)`;
|
|
148
|
+
details.push({ type: 'file', passed: false, message });
|
|
149
|
+
return { ok: false, status: 'failed', error: message, details };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
details.push({ type: 'file', passed: true, message: `File check passed for "${relPath}"` });
|
|
153
|
+
} else {
|
|
154
|
+
const message = `Unknown validator type "${type}"`;
|
|
155
|
+
details.push({ type: 'unknown', passed: false, message });
|
|
156
|
+
return { ok: false, status: 'failed', error: message, details };
|
|
157
|
+
}
|
|
158
|
+
} catch (valErr) {
|
|
159
|
+
const message = `Validator error in ${type}: ${valErr.message}`;
|
|
160
|
+
details.push({ type, passed: false, message });
|
|
161
|
+
return { ok: false, status: 'error', error: message, details };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { ok: true, status: 'passed', details };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getTargetText(runInfo, target) {
|
|
169
|
+
if (target === 'stdout') return String(runInfo.stdout ?? runInfo.output ?? '');
|
|
170
|
+
if (target === 'stderr') return String(runInfo.stderr ?? '');
|
|
171
|
+
return String(runInfo.output ?? '');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Sanitize and validate validator list for persistence.
|
|
176
|
+
*/
|
|
177
|
+
export function sanitizeValidators(validators) {
|
|
178
|
+
if (!Array.isArray(validators)) return [];
|
|
179
|
+
return validators.filter((v) => v && typeof v === 'object' && VALIDATOR_TYPES.includes(String(v.type).toLowerCase())).map((v) => ({
|
|
180
|
+
type: String(v.type).toLowerCase(),
|
|
181
|
+
...(v.pattern !== undefined ? { pattern: String(v.pattern) } : {}),
|
|
182
|
+
...(v.text !== undefined ? { text: String(v.text) } : {}),
|
|
183
|
+
...(v.flags !== undefined ? { flags: String(v.flags) } : {}),
|
|
184
|
+
...(v.negate !== undefined ? { negate: Boolean(v.negate) } : {}),
|
|
185
|
+
...(v.statusKey !== undefined ? { statusKey: String(v.statusKey) } : {}),
|
|
186
|
+
...(v.expected !== undefined ? { expected: v.expected } : {}),
|
|
187
|
+
...(v.path !== undefined ? { path: String(v.path) } : {}),
|
|
188
|
+
...(v.mustExist !== undefined ? { mustExist: Boolean(v.mustExist) } : {}),
|
|
189
|
+
...(v.minBytes !== undefined ? { minBytes: Number(v.minBytes) } : {}),
|
|
190
|
+
...(v.target !== undefined ? { target: String(v.target) } : {}),
|
|
191
|
+
}));
|
|
192
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-cron",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.51",
|
|
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",
|