@goodandready/dsh-cron 0.2.51 → 0.3.0

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
@@ -140,16 +140,10 @@ window.__ModuleLoader__.load({
140
140
  'history.stage_validation': 'Validation',
141
141
  'history.stage_delivery': 'Delivery',
142
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': '推送',
152
- 'history.statusSuccess': 'Success',
143
+ 'history.statusSuccess': 'Success', 'history.statusUncertain': 'Uncertain',
144
+ 'history.artifacts': 'Artifacts',
145
+ 'history.resolveCompleted': 'Mark Completed', 'history.resolveFailed': 'Mark Failed',
146
+ 'history.statusUncertainHint': 'Interrupted by restart — verify outcome',
153
147
  'history.statusSkipped': 'Skipped',
154
148
  'history.statusMissed': 'Missed',
155
149
  'history.statusTimeout': 'Timeout',
@@ -367,6 +361,12 @@ window.__ModuleLoader__.load({
367
361
  'form.targetSessionResetDaily': 'Daily rotation (new session each day)',
368
362
  'form.targetSessionResetWeekly': 'Weekly rotation (new session each week)',
369
363
  'form.targetSessionResetHint': 'Automatically rotates the session identifier to prevent context window explosion over long periods.',
364
+ 'form.requireApprovalLabel': 'Require human approval before execution',
365
+ 'form.requireApprovalHint': 'When enabled, the task will not execute until an authorized user approves the run proposal (#318).',
366
+ 'actions.approve': 'Approve', 'actions.reject': 'Reject',
367
+ 'approval.waiting': 'Awaiting human approval',
368
+ 'approval.approved': 'Approved', 'approval.rejected': 'Rejected',
369
+ 'recipe.dependenciesMissing': 'Missing required integrations or credentials',
370
370
  'form.previewSchedule': 'Preview next runs',
371
371
  'form.previewScheduleTitle': 'Next 5 scheduled runs:',
372
372
  },
@@ -507,6 +507,10 @@ window.__ModuleLoader__.load({
507
507
  'history.summaryCost': '预估成本: ${cost}',
508
508
  'history.errorLabel': '错误详情:',
509
509
  'history.outputLabel': '执行输出:',
510
+ 'history.statusUncertain': '不确定',
511
+ 'history.artifacts': '产物文件',
512
+ 'history.resolveCompleted': '标记已完成', 'history.resolveFailed': '标记失败',
513
+ 'history.statusUncertainHint': '服务重启中断 — 请确认结果',
510
514
  'form.titleLabel': '任务名称 *',
511
515
  'form.titlePlaceholder': '例如: 每周备份检查',
512
516
  'form.typeLabel': '执行类型 *',
@@ -616,6 +620,12 @@ window.__ModuleLoader__.load({
616
620
  'dsh.startFailed': '启动智能体对话失败: {error}',
617
621
  'dsh.launchError': '执行错误: {error}',
618
622
  'confirm.delete': '确定删除该任务吗?',
623
+ 'form.requireApprovalLabel': '执行前需人工确认审批',
624
+ 'form.requireApprovalHint': '开启后,任务执行前将挂起并等待授权用户批准提议 (#318)。',
625
+ 'actions.approve': '批准', 'actions.reject': '拒绝',
626
+ 'approval.waiting': '等待人工审批',
627
+ 'approval.approved': '已批准', 'approval.rejected': '已拒绝',
628
+ 'recipe.dependenciesMissing': '缺少必要集成服务或凭据',
619
629
  'list.loadErrorPrefix': '加载任务列表失败'
620
630
  }
621
631
  };
@@ -882,11 +892,6 @@ window.__ModuleLoader__.load({
882
892
  const STYLE_HEAL_KEY = '__dshCronStyleHeal';
883
893
  const STYLE_HEAL_INTERVAL_MS = 2000;
884
894
 
885
- /**
886
- * Keep the stylesheet attached: watch <head> (and <html>, which catches a
887
- * replaced <head>) and re-attach on demand, with a slow interval as the
888
- * backstop. heal() is idempotent and writes nothing while healthy.
889
- */
890
895
  function startStyleSelfHeal() {
891
896
  if (typeof document === 'undefined') return () => {};
892
897
  if (typeof window !== 'undefined' && typeof window[STYLE_HEAL_KEY] === 'function') {
@@ -989,7 +994,6 @@ window.__ModuleLoader__.load({
989
994
  const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
990
995
  const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
991
996
 
992
- /** Collect every delivery key the settings endpoints may return. */
993
997
  function parseEnvText(text) {
994
998
  const env = {};
995
999
  for (const rawLine of String(text || '').split('\n')) {
@@ -1083,15 +1087,10 @@ window.__ModuleLoader__.load({
1083
1087
  if (status === 'skipped') return T_KEY(t, 'history.statusSkipped');
1084
1088
  if (status === 'missed') return T_KEY(t, 'history.statusMissed');
1085
1089
  if (status === 'timeout') return T_KEY(t, 'history.statusTimeout');
1090
+ if (status === 'uncertain') return T_KEY(t, 'history.statusUncertain');
1086
1091
  return T_KEY(t, 'history.statusFailed');
1087
1092
  }
1088
1093
 
1089
- /**
1090
- * A config-declared task is owned by the profile config file (#50): the
1091
- * panel shows it, but editing, pausing or deleting it here would be undone
1092
- * at the next start, so the row keeps the source label instead of the
1093
- * actions that cannot stick.
1094
- */
1095
1094
  function isConfigManaged(task) {
1096
1095
  return Boolean(task) && task.managedBy === 'config';
1097
1096
  }
@@ -1099,10 +1098,6 @@ window.__ModuleLoader__.load({
1099
1098
  return (typeof t === 'function' ? t : translate)(key, vars);
1100
1099
  }
1101
1100
 
1102
- /**
1103
- * Issue #307: universal fetch with AbortController timeout (default 15s).
1104
- * Connects with caller signal if supplied and fails closed on timeouts.
1105
- */
1106
1101
  const fetchWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1107
1102
  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
1108
1103
  const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
@@ -1131,9 +1126,6 @@ window.__ModuleLoader__.load({
1131
1126
  }
1132
1127
  };
1133
1128
 
1134
- /**
1135
- * Issue #307: universal JSON fetch helper with timeout and aborted body protection.
1136
- */
1137
1129
  const fetchJsonWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1138
1130
  const res = await fetchWithTimeout(url, options, timeoutMs);
1139
1131
  let data = {};
@@ -1158,11 +1150,6 @@ window.__ModuleLoader__.load({
1158
1150
  return out;
1159
1151
  }
1160
1152
 
1161
- /**
1162
- * Shared delivery form used by both the task panel modal and the plugin
1163
- * settings card, so a new channel is added in exactly one place.
1164
- * Sections collapse via the canonical aria-expanded head contract.
1165
- */
1166
1153
  function renderDeliverySettings({ T, values, onChange, onChannelTemplate, sections, onToggleSection }) {
1167
1154
  const val = (key) => (values[key] === undefined || values[key] === null ? '' : String(values[key]));
1168
1155
  const field = (f) => React.createElement('div', { className: 'dsh-cron-form-group', key: f.key, style: { marginBottom: '10px' } },
@@ -1239,7 +1226,6 @@ window.__ModuleLoader__.load({
1239
1226
  ];
1240
1227
  }
1241
1228
 
1242
- /** Parse a KEY=VALUE textarea into the task env object (#38). */
1243
1229
  function CronScreen(props) {
1244
1230
  const { ctx, toggle, onClose } = props;
1245
1231
  const t = props.t || translate;
@@ -1272,6 +1258,7 @@ window.__ModuleLoader__.load({
1272
1258
  const [formFallbackModel, setFormFallbackModel] = React.useState('');
1273
1259
  const [formSilentRule, setFormSilentRule] = React.useState('');
1274
1260
  const [formInspectOnFailure, setFormInspectOnFailure] = React.useState(false);
1261
+ const [formRequireApproval, setFormRequireApproval] = React.useState(false);
1275
1262
  const [formDelivery, setFormDelivery] = React.useState('isolated');
1276
1263
  const [selectedTaskMeta, setSelectedTaskMeta] = React.useState(null);
1277
1264
  const [modalTab, setModalTab] = React.useState('params');
@@ -1341,6 +1328,7 @@ window.__ModuleLoader__.load({
1341
1328
  fallbackModel: String(formFallbackModel || ''),
1342
1329
  silentRule: String(formSilentRule || ''),
1343
1330
  inspectOnFailure: Boolean(formInspectOnFailure),
1331
+ requireApproval: Boolean(formRequireApproval),
1344
1332
  delivery: String(formDelivery || 'isolated'),
1345
1333
  notifyTelegram: Boolean(formNotifyTelegram),
1346
1334
  channels: Array.isArray(formChannels) ? [...formChannels].sort() : [],
@@ -1450,7 +1438,6 @@ window.__ModuleLoader__.load({
1450
1438
  return () => window.removeEventListener('keydown', onKey);
1451
1439
  }, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
1452
1440
 
1453
- /** #40: facet filtering happens client-side over the loaded list. */
1454
1441
  const visibleTasks = React.useMemo(() => { let list = tasks;
1455
1442
  if (filterType) list = list.filter((x) => (x.type || 'llm') === filterType);
1456
1443
  if (filterModel) list = list.filter((x) => String(x.model || '').split('/').pop() === filterModel);
@@ -1481,7 +1468,6 @@ window.__ModuleLoader__.load({
1481
1468
  setFilterChannel('');
1482
1469
  };
1483
1470
 
1484
- /** #34: scroll to and flash the task the sidebar list asked for. */
1485
1471
  React.useEffect(() => {
1486
1472
  if (!pendingTaskHighlight || tasks.length === 0) return;
1487
1473
  const id = pendingTaskHighlight;
@@ -1708,7 +1694,6 @@ window.__ModuleLoader__.load({
1708
1694
  }
1709
1695
  };
1710
1696
 
1711
- /** #41: duplicate a task; the server copies config, resets run state (#41). */
1712
1697
  const handleDryRun = async (taskId) => {
1713
1698
  try {
1714
1699
  const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/dry-run', { method: 'POST' });
@@ -1767,10 +1752,8 @@ window.__ModuleLoader__.load({
1767
1752
  }
1768
1753
  };
1769
1754
 
1770
- /** A task id must not shadow a collection route (#42 review finding). */
1771
1755
  const RESERVED_TASK_IDS = ['export', 'import'];
1772
1756
 
1773
- /** Import needs the script-confirm header when the file has code tasks. */
1774
1757
  const isCodeTaskForm = (x) => {
1775
1758
  if (!x || typeof x !== 'object') return false;
1776
1759
  if (CODE_FORM_TYPES.includes(x.type)) return true;
@@ -1787,7 +1770,6 @@ window.__ModuleLoader__.load({
1787
1770
  return headers;
1788
1771
  };
1789
1772
 
1790
- /** #42, #240: download the task configuration with honest disclosure and sanitized export choice. */
1791
1773
  const handleExport = async () => {
1792
1774
  setTransferBusy(true);
1793
1775
  try {
@@ -1817,7 +1799,6 @@ window.__ModuleLoader__.load({
1817
1799
  }
1818
1800
  };
1819
1801
 
1820
- /** #42: read the picked file, validate it server-side and show the plan. */
1821
1802
  const handleImportFile = async (e) => {
1822
1803
  const file = e.target.files && e.target.files[0];
1823
1804
  e.target.value = '';
@@ -1874,7 +1855,6 @@ window.__ModuleLoader__.load({
1874
1855
  }
1875
1856
  };
1876
1857
 
1877
- /** #49: tune an existing task in a dialogue instead of editing fields. */
1878
1858
  const handleTuneWithDsh = async (task) => {
1879
1859
  const lines = [
1880
1860
  `Tune the existing scheduled task "${task.title}" (id: ${task.id}).`,
@@ -1907,6 +1887,42 @@ window.__ModuleLoader__.load({
1907
1887
  }
1908
1888
  };
1909
1889
 
1890
+ const handleApproveProposal = async (taskId, proposalId) => {
1891
+ try {
1892
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/approve', {
1893
+ method: 'POST',
1894
+ headers: { 'Content-Type': 'application/json' },
1895
+ body: JSON.stringify({ proposalId, decidedBy: 'web-user' })
1896
+ });
1897
+ if (data && data.ok) {
1898
+ alert(T_KEY(t, 'approval.approved') || 'Approved');
1899
+ setTimeout(() => fetchTasks(), 1000);
1900
+ } else {
1901
+ alert('Approval failed: ' + ((data && data.error) || ''));
1902
+ }
1903
+ } catch (err) {
1904
+ alert('Approval request failed: ' + err.message);
1905
+ }
1906
+ };
1907
+
1908
+ const handleRejectProposal = async (taskId, proposalId) => {
1909
+ try {
1910
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/reject', {
1911
+ method: 'POST',
1912
+ headers: { 'Content-Type': 'application/json' },
1913
+ body: JSON.stringify({ proposalId, decidedBy: 'web-user' })
1914
+ });
1915
+ if (data && data.ok) {
1916
+ alert(T_KEY(t, 'approval.rejected') || 'Rejected');
1917
+ setTimeout(() => fetchTasks(), 1000);
1918
+ } else {
1919
+ alert('Rejection failed: ' + ((data && data.error) || ''));
1920
+ }
1921
+ } catch (err) {
1922
+ alert('Rejection request failed: ' + err.message);
1923
+ }
1924
+ };
1925
+
1910
1926
  const handleDelete = async (id) => {
1911
1927
  if (!confirm(T_KEY(t, 'confirm.delete'))) return;
1912
1928
  try {
@@ -1961,11 +1977,6 @@ window.__ModuleLoader__.load({
1961
1977
  }
1962
1978
  };
1963
1979
 
1964
- /**
1965
- * Update one delivery setting without dropping the rest of the object.
1966
- * `undefined` removes the key so the server default applies again; an
1967
- * empty string is kept so a text field can be cleared on purpose.
1968
- */
1969
1980
  const updateDelivery = (key, value) => {
1970
1981
  setSettingsDelivery((prev) => {
1971
1982
  const next = Object.assign({}, prev);
@@ -1975,7 +1986,6 @@ window.__ModuleLoader__.load({
1975
1986
  });
1976
1987
  };
1977
1988
 
1978
- /** Per-channel template overrides live in a nested map on the server. */
1979
1989
  const updateChannelTemplate = (channelId, value) => {
1980
1990
  setSettingsDelivery((prev) => {
1981
1991
  const templates = Object.assign({}, prev.channelTemplates || {});
@@ -2049,6 +2059,7 @@ window.__ModuleLoader__.load({
2049
2059
  setFormFallbackModel('');
2050
2060
  setFormSilentRule('');
2051
2061
  setFormInspectOnFailure(false);
2062
+ setFormRequireApproval(false);
2052
2063
  if (preset) {
2053
2064
  setFormTitle(preset.title || '');
2054
2065
  setFormSchedule(preset.schedule || '');
@@ -2058,6 +2069,7 @@ window.__ModuleLoader__.load({
2058
2069
  if (preset.silentRule !== undefined) setFormSilentRule(preset.silentRule || '');
2059
2070
  if (preset.fallbackModel !== undefined) setFormFallbackModel(preset.fallbackModel || '');
2060
2071
  if (preset.inspectOnFailure !== undefined) setFormInspectOnFailure(Boolean(preset.inspectOnFailure));
2072
+ if (preset.requireApproval !== undefined) setFormRequireApproval(Boolean(preset.requireApproval));
2061
2073
  setFormOnlyOnFailure(Boolean(preset.onlyOnFailure));
2062
2074
  setFormProvider(defaultModelInfo?.provider || '');
2063
2075
  setFormModel(defaultModelInfo?.model || '');
@@ -2080,6 +2092,7 @@ window.__ModuleLoader__.load({
2080
2092
  fallbackModel: String((preset && preset.fallbackModel) || '').trim(),
2081
2093
  silentRule: String((preset && preset.silentRule) || '').trim(),
2082
2094
  inspectOnFailure: Boolean(preset && preset.inspectOnFailure),
2095
+ requireApproval: Boolean(preset && preset.requireApproval),
2083
2096
  delivery: 'isolated',
2084
2097
  notifyTelegram: false,
2085
2098
  channels: Array.isArray(preset && preset.channels) ? [...preset.channels].filter((c) => DELIVERY_CHANNELS.includes(c)).sort() : [],
@@ -2146,6 +2159,23 @@ window.__ModuleLoader__.load({
2146
2159
  }
2147
2160
  };
2148
2161
 
2162
+ const handleResolveUncertain = async (taskId, runId, resolution) => {
2163
+ try {
2164
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/resolve-uncertain', {
2165
+ method: 'POST',
2166
+ headers: { 'Content-Type': 'application/json' },
2167
+ body: JSON.stringify({ runId, resolution }),
2168
+ });
2169
+ if (data && data.ok) {
2170
+ await loadTaskHistory(taskId);
2171
+ } else {
2172
+ alert(data?.error || 'Failed to resolve uncertain run');
2173
+ }
2174
+ } catch (e) {
2175
+ alert(e.message || 'Error resolving uncertain run');
2176
+ }
2177
+ };
2178
+
2149
2179
  const loadTaskHistory = async (taskId) => {
2150
2180
  setHistoryLoading(true);
2151
2181
  try {
@@ -2174,6 +2204,7 @@ window.__ModuleLoader__.load({
2174
2204
  setFormFallbackModel(task.fallbackModel || '');
2175
2205
  setFormSilentRule(task.silentRule || '');
2176
2206
  setFormInspectOnFailure(Boolean(task.inspectOnFailure));
2207
+ setFormRequireApproval(Boolean(task.requireApproval));
2177
2208
  setFormDelivery(task.delivery || 'isolated');
2178
2209
  setFormNotifyTelegram(Boolean(task.notifyTelegram));
2179
2210
  setFormChannels(Array.isArray(task.channels) ? task.channels.filter((c) => DELIVERY_CHANNELS.includes(c)) : []);
@@ -2271,6 +2302,7 @@ window.__ModuleLoader__.load({
2271
2302
  concurrencyGroup: formConcurrencyGroup.trim() || 'default',
2272
2303
  selfHealingCommand: formSelfHealingCommand.trim() || undefined,
2273
2304
  autoDiagnose: Boolean(formAutoDiagnose),
2305
+ requireApproval: Boolean(formRequireApproval),
2274
2306
  env: parseEnvText(formEnv),
2275
2307
  cwd: formCwd.trim(),
2276
2308
  workspaceId: formWorkspaceId.trim(),
@@ -2777,6 +2809,41 @@ window.__ModuleLoader__.load({
2777
2809
  onClick: () => handleRetryDelivery(formId, run.id)
2778
2810
  }, retryingDeliveryId === run.id ? T_KEY(t, 'history.retryingDelivery') : T_KEY(t, 'history.retryDelivery'))
2779
2811
  ),
2812
+ run.artifacts && run.artifacts.length > 0 && React.createElement('div', {
2813
+ style: { display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '6px', alignItems: 'center' }
2814
+ },
2815
+ React.createElement('span', { className: 'dsh-cron-hint', style: { fontSize: '11px' } }, '📦 ' + T_KEY(t, 'history.artifacts') + ':'),
2816
+ run.artifacts.map((art) => React.createElement('a', {
2817
+ key: art.name || art.id,
2818
+ href: '/dsh-cron/runs/' + encodeURIComponent(run.id) + '/artifacts/' + encodeURIComponent(art.name) + '/preview',
2819
+ target: '_blank',
2820
+ rel: 'noopener noreferrer',
2821
+ className: 'dsh-cron-btn-secondary',
2822
+ style: { padding: '2px 8px', fontSize: '11px', textDecoration: 'none' },
2823
+ title: art.name + ' (' + Math.round((art.size || 0) / 1024 * 10) / 10 + ' KB)'
2824
+ }, art.name))
2825
+ ),
2826
+ run.status === 'uncertain' && React.createElement('div', {
2827
+ className: 'dsh-cron-status-banner',
2828
+ 'data-type': 'warning',
2829
+ style: { marginTop: '8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }
2830
+ },
2831
+ React.createElement('span', null, '⚠️ ' + T_KEY(t, 'history.statusUncertainHint')),
2832
+ React.createElement('div', { style: { display: 'flex', gap: '6px' } },
2833
+ React.createElement('button', {
2834
+ type: 'button',
2835
+ className: 'dsh-cron-btn-secondary',
2836
+ style: { padding: '2px 8px', fontSize: '11px' },
2837
+ onClick: () => handleResolveUncertain(formId, run.id, 'completed')
2838
+ }, '✓ ' + T_KEY(t, 'history.resolveCompleted')),
2839
+ React.createElement('button', {
2840
+ type: 'button',
2841
+ className: 'dsh-cron-btn-secondary',
2842
+ style: { padding: '2px 8px', fontSize: '11px' },
2843
+ onClick: () => handleResolveUncertain(formId, run.id, 'failed')
2844
+ }, '✕ ' + T_KEY(t, 'history.resolveFailed'))
2845
+ )
2846
+ ),
2780
2847
  run.diagnosis && React.createElement('div', {
2781
2848
  className: 'dsh-cron-status-banner',
2782
2849
  'data-type': 'info',
@@ -3057,7 +3124,16 @@ window.__ModuleLoader__.load({
3057
3124
  }),
3058
3125
  React.createElement('span', null, T_KEY(t, 'form.inspectOnFailureLabel'))
3059
3126
  ),
3060
- formInspectOnFailure && React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.inspectOnFailureHint'))
3127
+ formInspectOnFailure && React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.inspectOnFailureHint')),
3128
+ React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginTop: '8px' } },
3129
+ React.createElement('input', {
3130
+ type: 'checkbox',
3131
+ checked: formRequireApproval,
3132
+ onChange: (e) => setFormRequireApproval(e.target.checked)
3133
+ }),
3134
+ React.createElement('span', null, T_KEY(t, 'form.requireApprovalLabel'))
3135
+ ),
3136
+ formRequireApproval && React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.requireApprovalHint'))
3061
3137
  ),
3062
3138
  formType === 'http' && React.createElement('div', { className: 'dsh-cron-form-row' },
3063
3139
  React.createElement('div', { className: 'dsh-cron-form-group' },
@@ -3291,7 +3367,7 @@ window.__ModuleLoader__.load({
3291
3367
  React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.silentRuleHint'))
3292
3368
  )
3293
3369
  ),
3294
-
3370
+
3295
3371
  React.createElement('div', {
3296
3372
  style: {
3297
3373
  marginTop: '16px',
@@ -3425,7 +3501,6 @@ window.__ModuleLoader__.load({
3425
3501
  )
3426
3502
  ),
3427
3503
 
3428
-
3429
3504
  dryRunResult && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setDryRunResult(null) },
3430
3505
  React.createElement('div', {
3431
3506
  className: 'dsh-cron-modal',
@@ -4107,18 +4182,9 @@ window.__ModuleLoader__.load({
4107
4182
  return entry;
4108
4183
  }
4109
4184
 
4110
- /**
4111
- * Collapsible list of active jobs under the sidebar entry (#34).
4112
- *
4113
- * Vanilla DOM like the entry itself: the sidebar is not a React root here.
4114
- * Collapsed by default, state persisted, capped at a few rows with an
4115
- * "and N more" line so a busy schedule cannot push the rest of the sidebar
4116
- * off screen. Clicking a row opens the panel and highlights that task.
4117
- */
4118
4185
  const SIDEBAR_JOBS_KEY = 'dsh-cron-sidebar-open';
4119
4186
  const SIDEBAR_JOBS_MAX_ROWS = 5;
4120
4187
 
4121
- /** Sidebar job row: title plus next run time or the live running state. */
4122
4188
  function buildSidebarJobRow(state, task) {
4123
4189
  const row = document.createElement('button');
4124
4190
  row.type = 'button';
@@ -4214,7 +4280,6 @@ window.__ModuleLoader__.load({
4214
4280
  return root;
4215
4281
  }
4216
4282
 
4217
- /** Keep the section mounted under the sidebar entry across re-renders. */
4218
4283
  function placeSidebarJobs(state, label) {
4219
4284
  if (state.root !== undefined && state.root.isConnected) return;
4220
4285
  const entry = document.querySelector('[' + ENTRY_ATTR + ']');
@@ -4230,14 +4295,6 @@ window.__ModuleLoader__.load({
4230
4295
  applySidebarJobsOpen(state);
4231
4296
  }
4232
4297
 
4233
- /**
4234
- * Collapsible list of active jobs under the sidebar entry (#34).
4235
- *
4236
- * Vanilla DOM like the entry itself: the sidebar is not a React root here.
4237
- * Collapsed by default, state persisted, capped at a few rows with an
4238
- * "and N more" line so a busy schedule cannot push the rest of the sidebar
4239
- * off screen. Clicking a row opens the panel and highlights that task.
4240
- */
4241
4298
  function mountSidebarJobs(toggle, label, openTaskSignal) {
4242
4299
  if (typeof document === 'undefined') return () => {};
4243
4300
  const state = { toggle, openTaskSignal, root: undefined, listEl: undefined, headEl: undefined, open: false };
@@ -4377,12 +4434,6 @@ window.__ModuleLoader__.load({
4377
4434
  };
4378
4435
  }
4379
4436
 
4380
- /**
4381
- * Register a card into a DSH mount point. Mount points are populated via
4382
- * ctx.slots.inject(mount, cb) — a direct slots.register does not throw but
4383
- * also never appears in the target surface (settings plugins tab, header),
4384
- * so the inject contract is mandatory (#85).
4385
- */
4386
4437
  function registerIntoMount(ctx, mountName, entry, component) {
4387
4438
  if (!ctx || !ctx.slots || typeof ctx.slots.inject !== 'function') return false;
4388
4439
  try {
package/lib/cron-tool.js CHANGED
@@ -152,6 +152,10 @@ 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
+ requireApproval: {
156
+ type: 'boolean',
157
+ description: 'Require explicit human approval before execution (#318)',
158
+ },
155
159
  validators: {
156
160
  type: 'array',
157
161
  description: 'Declarative post-execution validation checks (json, regex, text, exitCode, file)',
@@ -0,0 +1,188 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+
3
+ /**
4
+ * States for durable task execution lifecycle (#317)
5
+ */
6
+ export const JOURNAL_STATES = {
7
+ QUEUED: 'queued',
8
+ RUNNING: 'running',
9
+ COMPLETED: 'completed',
10
+ FAILED: 'failed',
11
+ CANCELLED: 'cancelled',
12
+ UNCERTAIN: 'uncertain',
13
+ };
14
+
15
+ export class DurableJournal {
16
+ constructor(store) {
17
+ this.store = store;
18
+ }
19
+
20
+ /**
21
+ * Generates a deterministic idempotency key for a task run
22
+ */
23
+ generateIdempotencyKey(taskId, scheduleTick) {
24
+ const raw = `${taskId}:${scheduleTick || Date.now()}`;
25
+ return 'idem_' + createHash('sha256').update(raw).digest('hex').slice(0, 24);
26
+ }
27
+
28
+ /**
29
+ * Checks whether a run with this idempotency key was already completed
30
+ */
31
+ isIdempotencyKeyExecuted(taskId, idempotencyKey) {
32
+ if (!idempotencyKey) return null;
33
+ const history = this.store.getHistory(taskId) || [];
34
+ return history.find(
35
+ (r) => r.idempotencyKey === idempotencyKey &&
36
+ (r.status === JOURNAL_STATES.COMPLETED || r.status === 'success')
37
+ ) || null;
38
+ }
39
+
40
+ /**
41
+ * Pre-allocates a durable run entry before execution starts (#317)
42
+ */
43
+ createRunRecord(taskId, { idempotencyKey, chainRunId, trigger = 'scheduler', scheduleTick } = {}) {
44
+ const runId = 'run_' + randomUUID().replace(/-/g, '').slice(0, 16);
45
+ const key = idempotencyKey || this.generateIdempotencyKey(taskId, scheduleTick);
46
+
47
+ const record = {
48
+ id: runId,
49
+ taskId,
50
+ at: Date.now(),
51
+ status: JOURNAL_STATES.QUEUED,
52
+ trigger,
53
+ idempotencyKey: key,
54
+ chainRunId: chainRunId || null,
55
+ stages: [
56
+ { name: 'queue', status: 'success', startedAt: Date.now(), finishedAt: Date.now() },
57
+ { name: 'preflight', status: 'pending' },
58
+ { name: 'execution', status: 'pending' },
59
+ { name: 'validation', status: 'pending' },
60
+ { name: 'delivery', status: 'pending' },
61
+ ],
62
+ stoppedReason: null,
63
+ createdAt: Date.now(),
64
+ };
65
+
66
+ // Record initial queued entry into history/store
67
+ this.store.recordRun(taskId, record);
68
+ return record;
69
+ }
70
+
71
+ /**
72
+ * Transitions a run record to a new state and updates history entry
73
+ */
74
+ transition(taskId, runId, toState, metadata = {}) {
75
+ const runs = this.store.history.get(taskId) || [];
76
+ const index = runs.findIndex((r) => r.id === runId);
77
+ if (index === -1) return null;
78
+
79
+ const existing = runs[index];
80
+ const updated = {
81
+ ...existing,
82
+ ...metadata,
83
+ status: toState,
84
+ updatedAt: Date.now(),
85
+ };
86
+
87
+ if (
88
+ toState === JOURNAL_STATES.COMPLETED ||
89
+ toState === JOURNAL_STATES.FAILED ||
90
+ toState === JOURNAL_STATES.CANCELLED ||
91
+ toState === JOURNAL_STATES.UNCERTAIN
92
+ ) {
93
+ updated.finishedAt = Date.now();
94
+ if (updated.startedAt && !updated.durationMs) {
95
+ updated.durationMs = updated.finishedAt - updated.startedAt;
96
+ }
97
+ }
98
+
99
+ runs[index] = updated;
100
+ this.store.history.set(taskId, runs);
101
+ this.store.scheduleSave(50);
102
+ return updated;
103
+ }
104
+
105
+ /**
106
+ * Recovers uncompleted / in-flight runs on daemon restart (#317)
107
+ */
108
+ recoverPendingRuns(scheduler) {
109
+ const tasks = this.store.list();
110
+ const recovered = { uncertain: [], requeued: [] };
111
+
112
+ for (const task of tasks) {
113
+ const history = this.store.history.get(task.id) || [];
114
+ let historyChanged = false;
115
+
116
+ for (let i = 0; i < history.length; i++) {
117
+ const run = history[i];
118
+ if (run.status === JOURNAL_STATES.RUNNING) {
119
+ // Crash during execution -> transition to UNCERTAIN to prevent blind rerun
120
+ history[i] = {
121
+ ...run,
122
+ status: JOURNAL_STATES.UNCERTAIN,
123
+ stoppedReason: 'interrupted_by_restart',
124
+ uncertainAt: Date.now(),
125
+ };
126
+ historyChanged = true;
127
+ recovered.uncertain.push({ taskId: task.id, runId: run.id });
128
+ } else if (run.status === JOURNAL_STATES.QUEUED) {
129
+ // Queued run that didn't start yet: if task is still active and recent (<1 hour), re-enqueue
130
+ const ageMs = Date.now() - (run.createdAt || run.at);
131
+ if (task.status === 'active' && ageMs < 3600000 && scheduler && typeof scheduler.runTask === 'function') {
132
+ scheduler.queue = scheduler.queue || [];
133
+ if (!scheduler.queue.includes(task.id)) {
134
+ scheduler.queue.push(task.id);
135
+ recovered.requeued.push({ taskId: task.id, runId: run.id });
136
+ }
137
+ } else {
138
+ // Expired or cancelled queued run
139
+ history[i] = {
140
+ ...run,
141
+ status: JOURNAL_STATES.CANCELLED,
142
+ stoppedReason: 'Queued run expired or cancelled across restart',
143
+ };
144
+ historyChanged = true;
145
+ }
146
+ }
147
+ }
148
+
149
+ if (historyChanged) {
150
+ this.store.scheduleSave(50);
151
+ }
152
+ }
153
+
154
+ return recovered;
155
+ }
156
+
157
+ /**
158
+ * Manually resolves an uncertain run record (#317)
159
+ */
160
+ resolveUncertain(taskId, runId, { decision, note, resolvedBy = 'user' } = {}) {
161
+ const runs = this.store.history.get(taskId) || [];
162
+ const index = runs.findIndex((r) => r.id === runId);
163
+ if (index === -1) return { ok: false, error: 'Run not found' };
164
+
165
+ const run = runs[index];
166
+ if (run.status !== JOURNAL_STATES.UNCERTAIN) {
167
+ return { ok: false, error: `Run is in status "${run.status}", not uncertain` };
168
+ }
169
+
170
+ let nextStatus = JOURNAL_STATES.FAILED;
171
+ if (decision === 'completed' || decision === 'mark_completed') nextStatus = JOURNAL_STATES.COMPLETED;
172
+ else if (decision === 'failed' || decision === 'mark_failed') nextStatus = JOURNAL_STATES.FAILED;
173
+ else if (decision === 'cancel' || decision === 'cancelled') nextStatus = JOURNAL_STATES.CANCELLED;
174
+
175
+ const resolved = {
176
+ ...run,
177
+ status: nextStatus,
178
+ stoppedReason: `Uncertain run resolved by ${resolvedBy}: ${note || decision}`,
179
+ resolvedBy,
180
+ resolvedAt: Date.now(),
181
+ };
182
+
183
+ runs[index] = resolved;
184
+ this.store.history.set(taskId, runs);
185
+ this.store.scheduleSave(50);
186
+ return { ok: true, run: resolved };
187
+ }
188
+ }