@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 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 base = String(settings.ttsBaseUrl || 'http://127.0.0.1:3080').replace(/\/+$/, '');
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
- // #43: a diagnosed failure shows what went wrong and
2757
- // offers to prefill the prompt with the suggestion.
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
- // Re-registration in the same session throws — the dictionary is live already.
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, options);
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: Date.now() - start,
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('http://127.0.0.1:3080').description('Base URL of the harness used to reach the dsh-tts speak route').volatile(),
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.
@@ -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
  }
@@ -74,6 +74,7 @@ export const PATCHABLE_TASK_FIELDS = [
74
74
  'dailyCostLimitUsd',
75
75
  'tokenLimit',
76
76
  'pausedReason',
77
+ 'validators',
77
78
  ];
78
79
 
79
80
  /** Fields a duplicate inherits; anything else is runtime state, not config. */
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.50",
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",