@goodandready/dsh-cron 0.2.50 → 0.2.52

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/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,17 @@ 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.stage_delivery': '推送',
147
144
  'history.statusSuccess': 'Success',
148
145
  'history.statusSkipped': 'Skipped',
149
146
  'history.statusMissed': 'Missed',
@@ -362,6 +359,14 @@ window.__ModuleLoader__.load({
362
359
  'form.targetSessionResetDaily': 'Daily rotation (new session each day)',
363
360
  'form.targetSessionResetWeekly': 'Weekly rotation (new session each week)',
364
361
  'form.targetSessionResetHint': 'Automatically rotates the session identifier to prevent context window explosion over long periods.',
362
+ 'form.requireApprovalLabel': 'Require human approval before execution',
363
+ 'form.requireApprovalHint': 'When enabled, the task will not execute until an authorized user approves the run proposal (#318).',
364
+ 'actions.approve': 'Approve',
365
+ 'actions.reject': 'Reject',
366
+ 'approval.waiting': 'Awaiting human approval',
367
+ 'approval.approved': 'Approved',
368
+ 'approval.rejected': 'Rejected',
369
+ 'recipe.dependenciesMissing': 'Missing required integrations or credentials',
365
370
  'form.previewSchedule': 'Preview next runs',
366
371
  'form.previewScheduleTitle': 'Next 5 scheduled runs:',
367
372
  },
@@ -611,12 +616,18 @@ window.__ModuleLoader__.load({
611
616
  'dsh.startFailed': '启动智能体对话失败: {error}',
612
617
  'dsh.launchError': '执行错误: {error}',
613
618
  'confirm.delete': '确定删除该任务吗?',
619
+ 'form.requireApprovalLabel': '执行前需人工确认审批',
620
+ 'form.requireApprovalHint': '开启后,任务执行前将挂起并等待授权用户批准提议 (#318)。',
621
+ 'actions.approve': '批准',
622
+ 'actions.reject': '拒绝',
623
+ 'approval.waiting': '等待人工审批',
624
+ 'approval.approved': '已批准',
625
+ 'approval.rejected': '已拒绝',
626
+ 'recipe.dependenciesMissing': '缺少必要集成服务或凭据',
614
627
  'list.loadErrorPrefix': '加载任务列表失败'
615
628
  }
616
629
  };
617
630
 
618
- // Module-level translator. apply() rebinds it to the DSH locale service so
619
- // the active language and live language switches are honored.
620
631
  let translate = (key, vars) => {
621
632
  let s = (STRINGS.en && STRINGS.en[key]) || key;
622
633
  if (vars) {
@@ -774,6 +785,12 @@ window.__ModuleLoader__.load({
774
785
  .dsh-cron-history-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
775
786
  .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
787
  .dsh-cron-history-status[data-status="success"] { background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); border-color: var(--dsh-cron-success); }
788
+ .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); }
789
+ .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); }
790
+ .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); }
791
+ .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); }
792
+ .dsh-cron-stage-pill[data-status="partial"] { background: var(--dsh-cron-warning-bg); color: var(--dsh-cron-warning); border-color: var(--dsh-cron-warning); }
793
+ .dsh-cron-stage-pill[data-status="skipped"] { opacity: 0.6; }
777
794
  .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
795
  .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
796
  .dsh-cron-history-time { color: var(--dsw-alias-label-tertiary); font-size: 12px; }
@@ -861,7 +878,6 @@ window.__ModuleLoader__.load({
861
878
  if (!head) return null;
862
879
  el = document.createElement('style');
863
880
  el.id = STYLE_ID;
864
- // Mark ownership before insertion so neighbor cleanups leave it alone (#91)
865
881
  el.dataset.dshPlugin = 'dsh-cron';
866
882
  el.textContent = STYLES;
867
883
  head.appendChild(el);
@@ -871,8 +887,6 @@ window.__ModuleLoader__.load({
871
887
  return el;
872
888
  }
873
889
 
874
- // Registry is shared across hot reloads so the newest instance owns exactly
875
- // one watcher instead of stacking one per module evaluation.
876
890
  const STYLE_HEAL_KEY = '__dshCronStyleHeal';
877
891
  const STYLE_HEAL_INTERVAL_MS = 2000;
878
892
 
@@ -947,7 +961,6 @@ window.__ModuleLoader__.load({
947
961
  const AGENT_FORM_TYPES = ['llm', 'skill', 'workflow'];
948
962
  const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
949
963
 
950
- // Delivery channels (#26) — must stay in sync with lib/channels.js.
951
964
  const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
952
965
  const CHANNEL_LABEL_KEYS = {
953
966
  telegram: 'form.channelTelegram',
@@ -961,15 +974,11 @@ window.__ModuleLoader__.load({
961
974
  gitea: 'form.channelGitea',
962
975
  };
963
976
 
964
- // Non-secret delivery settings shown in the settings panel. Secrets are
965
- // referenced by credential name only (#51) — never typed as values.
966
977
  const DELIVERY_SETTING_FIELDS = [
967
978
  { key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
968
979
  { key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
969
980
  { key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
970
981
  { 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
982
  { key: 'apiToken', labelKey: 'settings.apiTokenLabel', placeholderKey: 'settings.apiTokenPlaceholder', group: 'secrets' },
974
983
  { key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
975
984
  { key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
@@ -986,7 +995,6 @@ window.__ModuleLoader__.load({
986
995
  { key: 'inspectorModel', labelKey: 'settings.inspectorModelLabel', group: 'automation' },
987
996
  ];
988
997
  const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
989
- // The global template plus the per-channel override map (server model).
990
998
  const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
991
999
 
992
1000
  /** Collect every delivery key the settings endpoints may return. */
@@ -1009,8 +1017,6 @@ window.__ModuleLoader__.load({
1009
1017
  return Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n');
1010
1018
  }
1011
1019
 
1012
- // Status colors: core semantic state tokens first (same set dsh-clinebot
1013
- // uses), plugin fallbacks as a single point of change (#105).
1014
1020
  function createToggle() {
1015
1021
  let open = false;
1016
1022
  const listeners = new Set();
@@ -1037,7 +1043,6 @@ window.__ModuleLoader__.load({
1037
1043
  const getSessions = () => (ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : (ctx && ctx.sessions));
1038
1044
  let sessions = getSessions();
1039
1045
 
1040
- // 1. Try sessions.open(sessionId) immediately
1041
1046
  if (sessions && typeof sessions.open === 'function') {
1042
1047
  try {
1043
1048
  sessions.open(sessionId);
@@ -1047,7 +1052,6 @@ window.__ModuleLoader__.load({
1047
1052
  }
1048
1053
  }
1049
1054
 
1050
- // 2. If the session is not in manager.summaries yet, refresh from the backend
1051
1055
  if (sessions && typeof sessions.refresh === 'function') {
1052
1056
  try {
1053
1057
  await sessions.refresh();
@@ -1058,7 +1062,6 @@ window.__ModuleLoader__.load({
1058
1062
  }
1059
1063
  }
1060
1064
 
1061
- // 3. Async retries until the WebSocket/SSE event adds the session to the list
1062
1065
  for (let i = 0; i < 15; i++) {
1063
1066
  await new Promise(resolve => setTimeout(resolve, 120));
1064
1067
  sessions = getSessions();
@@ -1070,7 +1073,6 @@ window.__ModuleLoader__.load({
1070
1073
  /* retry polling sessions.open threw */
1071
1074
  }
1072
1075
  }
1073
- // DOM fallback: click the session in the sidebar if it has rendered
1074
1076
  if (typeof document !== 'undefined') {
1075
1077
  const domItem = document.querySelector('[data-session-id="' + sessionId + '"], [role="treeitem"][data-id="' + sessionId + '"], [data-id="' + sessionId + '"]');
1076
1078
  if (domItem) {
@@ -1085,6 +1087,7 @@ window.__ModuleLoader__.load({
1085
1087
 
1086
1088
  function statusLabel(t, status) {
1087
1089
  if (status === 'success') return T_KEY(t, 'history.statusSuccess');
1090
+ if (status === 'validation_failed') return T_KEY(t, 'history.statusValidationFailed');
1088
1091
  if (status === 'skipped') return T_KEY(t, 'history.statusSkipped');
1089
1092
  if (status === 'missed') return T_KEY(t, 'history.statusMissed');
1090
1093
  if (status === 'timeout') return T_KEY(t, 'history.statusTimeout');
@@ -1178,9 +1181,6 @@ window.__ModuleLoader__.load({
1178
1181
  step: 1000,
1179
1182
  value: val(f.key),
1180
1183
  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
1184
  onChange: (e) => onChange(f.key, e.target.value === ''
1185
1185
  ? (f.defaultValue !== undefined ? f.defaultValue : 15000)
1186
1186
  : Number(e.target.value)),
@@ -1253,8 +1253,6 @@ window.__ModuleLoader__.load({
1253
1253
  const t = props.t || translate;
1254
1254
  const [tab, setTab] = React.useState('all');
1255
1255
  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
1256
  const [filterType, setFilterType] = React.useState('');
1259
1257
  const [filterModel, setFilterModel] = React.useState('');
1260
1258
  const [filterChannel, setFilterChannel] = React.useState('');
@@ -1265,14 +1263,12 @@ window.__ModuleLoader__.load({
1265
1263
  const [fetchError, setFetchError] = React.useState(null);
1266
1264
  const [dropdownOpen, setDropdownOpen] = React.useState(false);
1267
1265
 
1268
- // Export/import state (#42)
1269
1266
  const [transferBusy, setTransferBusy] = React.useState(false);
1270
1267
  const [importDoc, setImportDoc] = React.useState(null);
1271
1268
  const [importSummary, setImportSummary] = React.useState(null);
1272
1269
  const [importStrategy, setImportStrategy] = React.useState('skip');
1273
1270
  const importFileRef = React.useRef(null);
1274
1271
 
1275
- // Manual / Edit modal state
1276
1272
  const [manualModalOpen, setManualModalOpen] = React.useState(false);
1277
1273
  const [formId, setFormId] = React.useState(null);
1278
1274
  const [formTitle, setFormTitle] = React.useState('');
@@ -1284,11 +1280,13 @@ window.__ModuleLoader__.load({
1284
1280
  const [formFallbackModel, setFormFallbackModel] = React.useState('');
1285
1281
  const [formSilentRule, setFormSilentRule] = React.useState('');
1286
1282
  const [formInspectOnFailure, setFormInspectOnFailure] = React.useState(false);
1283
+ const [formRequireApproval, setFormRequireApproval] = React.useState(false);
1287
1284
  const [formDelivery, setFormDelivery] = React.useState('isolated');
1288
1285
  const [selectedTaskMeta, setSelectedTaskMeta] = React.useState(null);
1289
1286
  const [modalTab, setModalTab] = React.useState('params');
1290
1287
  const [taskHistory, setTaskHistory] = React.useState([]);
1291
1288
  const [historyLoading, setHistoryLoading] = React.useState(false);
1289
+ const [retryingDeliveryId, setRetryingDeliveryId] = React.useState(null);
1292
1290
  const [formNotifyTelegram, setFormNotifyTelegram] = React.useState(false);
1293
1291
  const [formChannels, setFormChannels] = React.useState([]);
1294
1292
  const [formTemplate, setFormTemplate] = React.useState('');
@@ -1352,6 +1350,7 @@ window.__ModuleLoader__.load({
1352
1350
  fallbackModel: String(formFallbackModel || ''),
1353
1351
  silentRule: String(formSilentRule || ''),
1354
1352
  inspectOnFailure: Boolean(formInspectOnFailure),
1353
+ requireApproval: Boolean(formRequireApproval),
1355
1354
  delivery: String(formDelivery || 'isolated'),
1356
1355
  notifyTelegram: Boolean(formNotifyTelegram),
1357
1356
  channels: Array.isArray(formChannels) ? [...formChannels].sort() : [],
@@ -1414,8 +1413,6 @@ window.__ModuleLoader__.load({
1414
1413
  setManualModalOpen(false);
1415
1414
  };
1416
1415
 
1417
- // Live execution indicator (#35): a light 1s tick only while some task
1418
- // is actually running.
1419
1416
  const tasksRef = React.useRef([]);
1420
1417
  const [nowTick, setNowTick] = React.useState(0);
1421
1418
  React.useEffect(() => {
@@ -1428,7 +1425,6 @@ window.__ModuleLoader__.load({
1428
1425
  return () => clearInterval(tick);
1429
1426
  }, []);
1430
1427
 
1431
- // Settings modal state
1432
1428
  const [settingsModalOpen, setSettingsModalOpen] = React.useState(false);
1433
1429
  const [settingsBotToken, setSettingsBotToken] = React.useState('');
1434
1430
  const [settingsChatId, setSettingsChatId] = React.useState('');
@@ -1441,12 +1437,10 @@ window.__ModuleLoader__.load({
1441
1437
  const [settingsHasCustomBotToken, setSettingsHasCustomBotToken] = React.useState(false);
1442
1438
  const [testStatus, setTestStatus] = React.useState(null);
1443
1439
 
1444
- // DSH dialog state
1445
1440
  const [dshModalOpen, setDshModalOpen] = React.useState(false);
1446
1441
  const [dshPrompt, setDshPrompt] = React.useState('');
1447
1442
  const [dshLoading, setDshLoading] = React.useState(false);
1448
1443
 
1449
- // Models list state
1450
1444
  const [providers, setProviders] = React.useState([]);
1451
1445
  const [models, setModels] = React.useState([]);
1452
1446
  const [defaultModelInfo, setDefaultModelInfo] = React.useState(null);
@@ -1593,7 +1587,6 @@ window.__ModuleLoader__.load({
1593
1587
  .then(res => {
1594
1588
  if (isDestroyed || pollGen !== fetchGenRef.current) return null;
1595
1589
  if (res.status === 304) {
1596
- // Not modified: skip parsing JSON
1597
1590
  return null;
1598
1591
  }
1599
1592
  const etag = res.headers.get('ETag');
@@ -1614,13 +1607,11 @@ window.__ModuleLoader__.load({
1614
1607
  .finally(() => {
1615
1608
  if (pollTimer) clearTimeout(pollTimer);
1616
1609
  if (isDestroyed) return;
1617
- // Adaptive interval: 30s when tab is hidden, 8s when active (#134)
1618
1610
  const intervalMs = (typeof document !== 'undefined' && document.hidden) ? 30000 : 8000;
1619
1611
  timerId = setTimeout(doPoll, intervalMs);
1620
1612
  });
1621
1613
  };
1622
1614
 
1623
- // Trigger immediate fetch when visibility returns
1624
1615
  const onVisibilityChange = () => {
1625
1616
  if (typeof document !== 'undefined' && !document.hidden) {
1626
1617
  if (timerId) clearTimeout(timerId);
@@ -1666,7 +1657,6 @@ window.__ModuleLoader__.load({
1666
1657
  try {
1667
1658
  data = await res.json();
1668
1659
  } catch (jsonErr) {
1669
- // Re-throw if body reading aborted/timed out (#242)
1670
1660
  if (jsonErr && (jsonErr.name === 'AbortError' || controller?.signal.aborted)) {
1671
1661
  throw jsonErr;
1672
1662
  }
@@ -1781,8 +1771,6 @@ window.__ModuleLoader__.load({
1781
1771
  return;
1782
1772
  }
1783
1773
  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
1774
  alert(task.oneShot ? T_KEY(t, 'duplicate.oneShotHint') : T_KEY(t, 'duplicate.done'));
1787
1775
  } catch (err) {
1788
1776
  alert(T_KEY(t, 'duplicate.failed', { error: err.message }));
@@ -1929,6 +1917,42 @@ window.__ModuleLoader__.load({
1929
1917
  }
1930
1918
  };
1931
1919
 
1920
+ const handleApproveProposal = async (taskId, proposalId) => {
1921
+ try {
1922
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/approve', {
1923
+ method: 'POST',
1924
+ headers: { 'Content-Type': 'application/json' },
1925
+ body: JSON.stringify({ proposalId, decidedBy: 'web-user' })
1926
+ });
1927
+ if (data && data.ok) {
1928
+ alert(T_KEY(t, 'approval.approved') || 'Approved');
1929
+ setTimeout(() => fetchTasks(), 1000);
1930
+ } else {
1931
+ alert('Approval failed: ' + ((data && data.error) || ''));
1932
+ }
1933
+ } catch (err) {
1934
+ alert('Approval request failed: ' + err.message);
1935
+ }
1936
+ };
1937
+
1938
+ const handleRejectProposal = async (taskId, proposalId) => {
1939
+ try {
1940
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/reject', {
1941
+ method: 'POST',
1942
+ headers: { 'Content-Type': 'application/json' },
1943
+ body: JSON.stringify({ proposalId, decidedBy: 'web-user' })
1944
+ });
1945
+ if (data && data.ok) {
1946
+ alert(T_KEY(t, 'approval.rejected') || 'Rejected');
1947
+ setTimeout(() => fetchTasks(), 1000);
1948
+ } else {
1949
+ alert('Rejection failed: ' + ((data && data.error) || ''));
1950
+ }
1951
+ } catch (err) {
1952
+ alert('Rejection request failed: ' + err.message);
1953
+ }
1954
+ };
1955
+
1932
1956
  const handleDelete = async (id) => {
1933
1957
  if (!confirm(T_KEY(t, 'confirm.delete'))) return;
1934
1958
  try {
@@ -2068,20 +2092,20 @@ window.__ModuleLoader__.load({
2068
2092
  setFormPythonPath('');
2069
2093
  setFormSkillName('');
2070
2094
  setFormWorkflowName('');
2071
- // Reset before the preset branch, so a recipe keeps what it sets.
2072
2095
  setFormFallbackModel('');
2073
2096
  setFormSilentRule('');
2074
2097
  setFormInspectOnFailure(false);
2098
+ setFormRequireApproval(false);
2075
2099
  if (preset) {
2076
2100
  setFormTitle(preset.title || '');
2077
2101
  setFormSchedule(preset.schedule || '');
2078
2102
  setFormPrompt(preset.prompt || '');
2079
- // #48: a recipe preset also carries its runtime and delivery shape.
2080
2103
  if (preset.type) setFormType(preset.type);
2081
2104
  if (Array.isArray(preset.channels) && preset.channels.length) setFormChannels(preset.channels.filter((c) => DELIVERY_CHANNELS.includes(c)));
2082
2105
  if (preset.silentRule !== undefined) setFormSilentRule(preset.silentRule || '');
2083
2106
  if (preset.fallbackModel !== undefined) setFormFallbackModel(preset.fallbackModel || '');
2084
2107
  if (preset.inspectOnFailure !== undefined) setFormInspectOnFailure(Boolean(preset.inspectOnFailure));
2108
+ if (preset.requireApproval !== undefined) setFormRequireApproval(Boolean(preset.requireApproval));
2085
2109
  setFormOnlyOnFailure(Boolean(preset.onlyOnFailure));
2086
2110
  setFormProvider(defaultModelInfo?.provider || '');
2087
2111
  setFormModel(defaultModelInfo?.model || '');
@@ -2104,6 +2128,7 @@ window.__ModuleLoader__.load({
2104
2128
  fallbackModel: String((preset && preset.fallbackModel) || '').trim(),
2105
2129
  silentRule: String((preset && preset.silentRule) || '').trim(),
2106
2130
  inspectOnFailure: Boolean(preset && preset.inspectOnFailure),
2131
+ requireApproval: Boolean(preset && preset.requireApproval),
2107
2132
  delivery: 'isolated',
2108
2133
  notifyTelegram: false,
2109
2134
  channels: Array.isArray(preset && preset.channels) ? [...preset.channels].filter((c) => DELIVERY_CHANNELS.includes(c)).sort() : [],
@@ -2150,6 +2175,26 @@ window.__ModuleLoader__.load({
2150
2175
  setManualModalOpen(true);
2151
2176
  };
2152
2177
 
2178
+ const handleRetryDelivery = async (taskId, runId) => {
2179
+ setRetryingDeliveryId(runId);
2180
+ try {
2181
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/retry-delivery', {
2182
+ method: 'POST',
2183
+ headers: { 'Content-Type': 'application/json' },
2184
+ body: JSON.stringify({ runId }),
2185
+ });
2186
+ if (data && data.ok) {
2187
+ await loadTaskHistory(taskId);
2188
+ } else {
2189
+ alert(data?.error || 'Failed to retry delivery');
2190
+ }
2191
+ } catch (e) {
2192
+ alert(e.message || 'Error retrying delivery');
2193
+ } finally {
2194
+ setRetryingDeliveryId(null);
2195
+ }
2196
+ };
2197
+
2153
2198
  const loadTaskHistory = async (taskId) => {
2154
2199
  setHistoryLoading(true);
2155
2200
  try {
@@ -2178,6 +2223,7 @@ window.__ModuleLoader__.load({
2178
2223
  setFormFallbackModel(task.fallbackModel || '');
2179
2224
  setFormSilentRule(task.silentRule || '');
2180
2225
  setFormInspectOnFailure(Boolean(task.inspectOnFailure));
2226
+ setFormRequireApproval(Boolean(task.requireApproval));
2181
2227
  setFormDelivery(task.delivery || 'isolated');
2182
2228
  setFormNotifyTelegram(Boolean(task.notifyTelegram));
2183
2229
  setFormChannels(Array.isArray(task.channels) ? task.channels.filter((c) => DELIVERY_CHANNELS.includes(c)) : []);
@@ -2275,6 +2321,7 @@ window.__ModuleLoader__.load({
2275
2321
  concurrencyGroup: formConcurrencyGroup.trim() || 'default',
2276
2322
  selfHealingCommand: formSelfHealingCommand.trim() || undefined,
2277
2323
  autoDiagnose: Boolean(formAutoDiagnose),
2324
+ requireApproval: Boolean(formRequireApproval),
2278
2325
  env: parseEnvText(formEnv),
2279
2326
  cwd: formCwd.trim(),
2280
2327
  workspaceId: formWorkspaceId.trim(),
@@ -2293,8 +2340,6 @@ window.__ModuleLoader__.load({
2293
2340
  skillName: formSkillName.trim(),
2294
2341
  workflowName: formWorkflowName.trim(),
2295
2342
  };
2296
- // Cross-origin hardening: every code-executing type needs the explicit
2297
- // confirm header, which forged cross-site posts cannot attach (#86)
2298
2343
  const headers = { 'Content-Type': 'application/json' };
2299
2344
  const hasCodeExec = CODE_FORM_TYPES.includes(formType) ||
2300
2345
  Boolean(payload.command && payload.command.trim()) ||
@@ -2391,7 +2436,6 @@ window.__ModuleLoader__.load({
2391
2436
  React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: () => openManualModal(null) }, T_KEY(t, 'actions.setupManually'))
2392
2437
  )
2393
2438
  ),
2394
- // #42: export/import as a small pair of icon buttons.
2395
2439
  React.createElement('button', {
2396
2440
  type: 'button',
2397
2441
  className: 'dsh-cron-icon-btn',
@@ -2460,7 +2504,6 @@ window.__ModuleLoader__.load({
2460
2504
  : T_KEY(t, 'tabs.completed'))
2461
2505
  )
2462
2506
  ),
2463
- // #40: facet filters over the loaded list.
2464
2507
  React.createElement('div', { className: 'dsh-cron-filters' },
2465
2508
  React.createElement('button', {
2466
2509
  type: 'button',
@@ -2692,7 +2735,6 @@ window.__ModuleLoader__.load({
2692
2735
  )
2693
2736
  ),
2694
2737
 
2695
- // 1. Manual creation / Edit modal
2696
2738
  manualModalOpen && React.createElement('div', {
2697
2739
  className: 'dsh-cron-modal-overlay',
2698
2740
  onClick: (e) => {
@@ -2753,8 +2795,39 @@ window.__ModuleLoader__.load({
2753
2795
  React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary, #888)', fontSize: '11.5px' } }, (run.durationMs || 0) + ' ms')
2754
2796
  )
2755
2797
  ),
2756
- // #43: a diagnosed failure shows what went wrong and
2757
- // offers to prefill the prompt with the suggestion.
2798
+ run.stoppedReason && React.createElement('div', {
2799
+ className: 'dsh-cron-hint',
2800
+ style: { display: 'flex', gap: '6px', alignItems: 'center', marginTop: '6px' }
2801
+ },
2802
+ React.createElement('span', { className: 'dsh-cron-badge-ref' }, T_KEY(t, 'history.stoppedReasonTitle')),
2803
+ React.createElement('span', null, run.stoppedReason)
2804
+ ),
2805
+ Array.isArray(run.stages) && run.stages.length > 0 && React.createElement('div', {
2806
+ className: 'dsh-cron-stages-flow',
2807
+ style: { display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '6px' }
2808
+ },
2809
+ run.stages.map((st) => React.createElement('span', {
2810
+ key: st.name,
2811
+ className: 'dsh-cron-stage-pill',
2812
+ 'data-status': st.status || 'unknown',
2813
+ title: st.error ? (st.name + ': ' + st.error) : (st.name + (st.durationMs !== undefined ? ' (' + st.durationMs + 'ms)' : ''))
2814
+ }, (T_KEY(t, 'history.stage_' + st.name) || st.name) + (st.durationMs !== undefined ? ' ' + st.durationMs + 'ms' : '')))
2815
+ ),
2816
+ run.delivery && React.createElement('div', {
2817
+ style: { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '6px' }
2818
+ },
2819
+ React.createElement('span', {
2820
+ className: 'dsh-cron-badge-ref',
2821
+ 'data-status': run.delivery.status
2822
+ }, 'Delivery: ' + run.delivery.status),
2823
+ (run.delivery.status === 'failed' || run.delivery.status === 'partial') && React.createElement('button', {
2824
+ type: 'button',
2825
+ className: 'dsh-cron-btn-secondary',
2826
+ style: { padding: '2px 8px', fontSize: '11px' },
2827
+ disabled: retryingDeliveryId === run.id,
2828
+ onClick: () => handleRetryDelivery(formId, run.id)
2829
+ }, retryingDeliveryId === run.id ? T_KEY(t, 'history.retryingDelivery') : T_KEY(t, 'history.retryDelivery'))
2830
+ ),
2758
2831
  run.diagnosis && React.createElement('div', {
2759
2832
  className: 'dsh-cron-status-banner',
2760
2833
  'data-type': 'info',
@@ -2774,7 +2847,6 @@ window.__ModuleLoader__.load({
2774
2847
  }, T_KEY(t, 'history.applySuggestion'))
2775
2848
  )
2776
2849
  ),
2777
- // #44: a run suppressed by its silent rule says why.
2778
2850
  run.silentSkip && React.createElement('div', {
2779
2851
  className: 'dsh-cron-hint',
2780
2852
  style: { display: 'flex', gap: '6px', alignItems: 'center' }
@@ -3018,7 +3090,6 @@ window.__ModuleLoader__.load({
3018
3090
  models.map((m) => React.createElement('option', { key: m.id, value: m.id }, m.name || m.id))
3019
3091
  )
3020
3092
  ),
3021
- // #45: one retry on a stronger model when the cheap one fails.
3022
3093
  React.createElement('div', { className: 'dsh-cron-form-group' },
3023
3094
  React.createElement('label', null, T_KEY(t, 'form.fallbackModelLabel')),
3024
3095
  React.createElement('input', {
@@ -3029,7 +3100,6 @@ window.__ModuleLoader__.load({
3029
3100
  }),
3030
3101
  React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.fallbackModelHint'))
3031
3102
  ),
3032
- // #43: diagnose failures with a model.
3033
3103
  React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginTop: '4px' } },
3034
3104
  React.createElement('input', {
3035
3105
  type: 'checkbox',
@@ -3038,7 +3108,16 @@ window.__ModuleLoader__.load({
3038
3108
  }),
3039
3109
  React.createElement('span', null, T_KEY(t, 'form.inspectOnFailureLabel'))
3040
3110
  ),
3041
- formInspectOnFailure && React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.inspectOnFailureHint'))
3111
+ formInspectOnFailure && React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.inspectOnFailureHint')),
3112
+ React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginTop: '8px' } },
3113
+ React.createElement('input', {
3114
+ type: 'checkbox',
3115
+ checked: formRequireApproval,
3116
+ onChange: (e) => setFormRequireApproval(e.target.checked)
3117
+ }),
3118
+ React.createElement('span', null, T_KEY(t, 'form.requireApprovalLabel'))
3119
+ ),
3120
+ formRequireApproval && React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.requireApprovalHint'))
3042
3121
  ),
3043
3122
  formType === 'http' && React.createElement('div', { className: 'dsh-cron-form-row' },
3044
3123
  React.createElement('div', { className: 'dsh-cron-form-group' },
@@ -3261,7 +3340,6 @@ window.__ModuleLoader__.load({
3261
3340
  }),
3262
3341
  React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.templateHint'))
3263
3342
  ),
3264
- // #44: output-producing runtimes may stay silent by rule.
3265
3343
  !AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-group', style: { marginTop: '12px', marginBottom: 0 } },
3266
3344
  React.createElement('label', null, T_KEY(t, 'form.silentRuleLabel')),
3267
3345
  React.createElement('input', {
@@ -3274,7 +3352,6 @@ window.__ModuleLoader__.load({
3274
3352
  )
3275
3353
  ),
3276
3354
 
3277
- // Reliability, Heartbeat & Self-Healing section
3278
3355
  React.createElement('div', {
3279
3356
  style: {
3280
3357
  marginTop: '16px',
@@ -3409,7 +3486,6 @@ window.__ModuleLoader__.load({
3409
3486
  ),
3410
3487
 
3411
3488
 
3412
- // Dry Run simulation modal
3413
3489
  dryRunResult && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setDryRunResult(null) },
3414
3490
  React.createElement('div', {
3415
3491
  className: 'dsh-cron-modal',
@@ -3456,7 +3532,6 @@ window.__ModuleLoader__.load({
3456
3532
  )
3457
3533
  ),
3458
3534
 
3459
- // Archive runs modal
3460
3535
  archiveModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setArchiveModalOpen(false) },
3461
3536
  React.createElement('div', {
3462
3537
  className: 'dsh-cron-modal',
@@ -3524,7 +3599,6 @@ window.__ModuleLoader__.load({
3524
3599
  )
3525
3600
  ),
3526
3601
 
3527
- // 2. Settings modal
3528
3602
  settingsModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setSettingsModalOpen(false) },
3529
3603
  React.createElement('div', { className: 'dsh-cron-modal dsh-cron-modal-scroll', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
3530
3604
  React.createElement('div', { className: 'dsh-cron-modal-head', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '18px' } },
@@ -3611,7 +3685,6 @@ window.__ModuleLoader__.load({
3611
3685
  )
3612
3686
  ),
3613
3687
 
3614
- // 3. Import modal (#42) — summary first, then an explicit strategy.
3615
3688
  importDoc && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !transferBusy && setImportDoc(null) },
3616
3689
  React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
3617
3690
  React.createElement('div', { className: 'dsh-cron-modal-title' }, T_KEY(t, 'transfer.importTitleModal')),
@@ -3654,7 +3727,6 @@ window.__ModuleLoader__.load({
3654
3727
  )
3655
3728
  ),
3656
3729
 
3657
- // 4. "Create with DSH" modal
3658
3730
  dshModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !dshLoading && setDshModalOpen(false) },
3659
3731
  React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
3660
3732
  React.createElement('div', { className: 'dsh-cron-modal-title' }, T_KEY(t, 'dsh.modalTitle')),
@@ -3717,7 +3789,6 @@ window.__ModuleLoader__.load({
3717
3789
  const [hasDefault, setHasDefault] = React.useState(false);
3718
3790
  const [saved, setSaved] = React.useState(false);
3719
3791
  const [testInfo, setTestInfo] = React.useState(null);
3720
- // Collapsed by default, head is the toggle (canonical card contract, #100)
3721
3792
  const [cardOpen, setCardOpen] = React.useState(false);
3722
3793
  const [updateState, setUpdateState] = React.useState({
3723
3794
  checking: false,
@@ -3777,8 +3848,6 @@ window.__ModuleLoader__.load({
3777
3848
  }
3778
3849
  };
3779
3850
 
3780
- // Settings snapshot status (#102): never render phantom inputs before
3781
- // the snapshot arrives; offer a retry when it is unavailable.
3782
3851
  const [loadState, setLoadState] = React.useState('loading');
3783
3852
  const [reloadTick, setReloadTick] = React.useState(0);
3784
3853
 
@@ -3887,8 +3956,6 @@ window.__ModuleLoader__.load({
3887
3956
  );
3888
3957
  }
3889
3958
 
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
3959
  if (props && props.view === 'summary') {
3893
3960
  return React.createElement('span', { className: 'dsh-cron-card-desc' }, T_KEY(t, 'settings.cardDesc'));
3894
3961
  }
@@ -4116,8 +4183,6 @@ window.__ModuleLoader__.load({
4116
4183
  const row = document.createElement('button');
4117
4184
  row.type = 'button';
4118
4185
  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
4186
  row.setAttribute('data-sidebar-task-id', task.id);
4122
4187
  row.setAttribute('aria-label', task.title || task.id);
4123
4188
 
@@ -4218,7 +4283,6 @@ window.__ModuleLoader__.load({
4218
4283
  entry.insertAdjacentElement('afterend', buildSidebarJobsRoot(state, label));
4219
4284
  } else {
4220
4285
  entry.insertAdjacentElement('afterend', state.root);
4221
- // Re-query the refs: the previous nodes may belong to a detached tree.
4222
4286
  state.root = document.querySelector('[data-dsh-part="sidebar-jobs"]') || state.root;
4223
4287
  state.listEl = state.root.querySelector('.dsh-cron-sidebar-jobs-list');
4224
4288
  state.headEl = state.root.querySelector('.dsh-cron-sidebar-jobs-head');
@@ -4335,8 +4399,6 @@ window.__ModuleLoader__.load({
4335
4399
 
4336
4400
  const applyActive = () => {
4337
4401
  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
4402
  ensureStyles();
4341
4403
  document.documentElement.setAttribute(ACTIVE_ATTR, '');
4342
4404
  document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL }));
@@ -4396,14 +4458,12 @@ window.__ModuleLoader__.load({
4396
4458
  function apply(ctx) {
4397
4459
  const stopStyleSelfHeal = startStyleSelfHeal();
4398
4460
 
4399
- // Register the English canonical strings; rebinding the translator lets
4400
- // the translation plugin drive the active language at runtime (#87).
4401
4461
  try {
4402
4462
  if (ctx.locale && typeof ctx.locale.register === 'function') {
4403
4463
  ctx.locale.register(NS, STRINGS);
4404
4464
  }
4405
4465
  } catch (e) {
4406
- // Re-registration in the same session throws — the dictionary is live already.
4466
+ void e;
4407
4467
  }
4408
4468
  try {
4409
4469
  if (ctx.locale && typeof ctx.locale.bind === 'function') {
@@ -4415,16 +4475,9 @@ window.__ModuleLoader__.load({
4415
4475
 
4416
4476
  const toggle = createToggle();
4417
4477
 
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
4478
  function SettingsCardWithBoundary(props) {
4422
4479
  return React.createElement(ErrorBoundary, null, CronSettingsCard(props));
4423
4480
  }
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
4481
  const itemOk = registerIntoMount(ctx, 'plugins.item', {
4429
4482
  name: 'plugins.item',
4430
4483
  id: ROW_ID,
@@ -4436,8 +4489,6 @@ window.__ModuleLoader__.load({
4436
4489
  if (!itemOk) {
4437
4490
  console.warn('[dsh-cron] plugins.item slot unavailable — plugin page card not registered');
4438
4491
  }
4439
- // Row seat and the legacy settings.plugin.item seat stay as fallbacks
4440
- // (#102: still no top-level section fallback).
4441
4492
  const rowOk = registerIntoMount(ctx, 'plugins.row.config', {
4442
4493
  name: 'plugins.row.config',
4443
4494
  key: ROW_CONFIG_KEY,