@goodandready/dsh-cron 0.2.52 → 0.3.1

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,8 +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.stage_delivery': '推送',
144
- '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',
145
147
  'history.statusSkipped': 'Skipped',
146
148
  'history.statusMissed': 'Missed',
147
149
  'history.statusTimeout': 'Timeout',
@@ -286,14 +288,12 @@ window.__ModuleLoader__.load({
286
288
  'settings.kanbanUrlLabel': 'Kanban Base URL',
287
289
  'settings.globalNotify': 'Deliver reports of all tasks to Telegram globally',
288
290
  'settings.globalOnlyFailure': 'Global mode: only on failures (silent on success)',
289
- 'settings.testTelegram': '🔔 Test Telegram delivery',
290
- 'settings.testKanban': '📋 Test Kanban',
291
- 'settings.sending': 'Sending...',
292
- 'settings.checkingKanban': 'Checking Kanban...',
293
- 'settings.testOk': '✅ Test message delivered to Telegram!',
294
- 'settings.testFail': '❌ {error}',
295
- 'settings.kanbanOk': '✅ Kanban card created',
296
- 'settings.kanbanFail': '❌ {error}',
291
+ 'settings.dynamicCredentials': 'Dynamic Credentials Resolution (credential://...)',
292
+ 'settings.dynamicCredentialsHint': 'Resolve credential://... in memory from harness credentials at runtime (#335)',
293
+ 'settings.testTelegram': '🔔 Test Telegram delivery', 'settings.testKanban': '📋 Test Kanban',
294
+ 'settings.sending': 'Sending...', 'settings.checkingKanban': 'Checking Kanban...',
295
+ 'settings.testOk': '✅ Test message delivered to Telegram!', 'settings.testFail': '❌ {error}',
296
+ 'settings.kanbanOk': '✅ Kanban card created', 'settings.kanbanFail': '❌ {error}',
297
297
  'settings.save': 'Save settings',
298
298
  'settings.saved': '✓ Saved',
299
299
  'settings.saveError': 'Save failed: {error}',
@@ -361,11 +361,9 @@ window.__ModuleLoader__.load({
361
361
  'form.targetSessionResetHint': 'Automatically rotates the session identifier to prevent context window explosion over long periods.',
362
362
  'form.requireApprovalLabel': 'Require human approval before execution',
363
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',
364
+ 'actions.approve': 'Approve', 'actions.reject': 'Reject',
366
365
  'approval.waiting': 'Awaiting human approval',
367
- 'approval.approved': 'Approved',
368
- 'approval.rejected': 'Rejected',
366
+ 'approval.approved': 'Approved', 'approval.rejected': 'Rejected',
369
367
  'recipe.dependenciesMissing': 'Missing required integrations or credentials',
370
368
  'form.previewSchedule': 'Preview next runs',
371
369
  'form.previewScheduleTitle': 'Next 5 scheduled runs:',
@@ -507,6 +505,10 @@ window.__ModuleLoader__.load({
507
505
  'history.summaryCost': '预估成本: ${cost}',
508
506
  'history.errorLabel': '错误详情:',
509
507
  'history.outputLabel': '执行输出:',
508
+ 'history.statusUncertain': '不确定',
509
+ 'history.artifacts': '产物文件',
510
+ 'history.resolveCompleted': '标记已完成', 'history.resolveFailed': '标记失败',
511
+ 'history.statusUncertainHint': '服务重启中断 — 请确认结果',
510
512
  'form.titleLabel': '任务名称 *',
511
513
  'form.titlePlaceholder': '例如: 每周备份检查',
512
514
  'form.typeLabel': '执行类型 *',
@@ -586,14 +588,12 @@ window.__ModuleLoader__.load({
586
588
  'settings.kanbanUrlLabel': 'Kanban 服务基础地址',
587
589
  'settings.globalNotify': '全局开启所有任务结果推送至 Telegram',
588
590
  'settings.globalOnlyFailure': '全局模式:仅在失败时推送 (成功静默)',
589
- 'settings.testTelegram': '🔔 测试 Telegram 发送',
590
- 'settings.testKanban': '📋 测试 Kanban',
591
- 'settings.sending': '正在发送...',
592
- 'settings.checkingKanban': '正在连接 Kanban...',
593
- 'settings.testOk': '✅ 测试消息已成功发送至 Telegram!',
594
- 'settings.testFail': '❌ 发送失败: {error}',
595
- 'settings.kanbanOk': '✅ Kanban 卡片创建成功',
596
- 'settings.kanbanFail': '❌ {error}',
591
+ 'settings.dynamicCredentials': '动态凭据解析 (credential://...)',
592
+ 'settings.dynamicCredentialsHint': '运行时从凭据服务动态解析 credential://... 且不泄露到存储 (#335)',
593
+ 'settings.testTelegram': '🔔 测试 Telegram 发送', 'settings.testKanban': '📋 测试 Kanban',
594
+ 'settings.sending': '正在发送...', 'settings.checkingKanban': '正在连接 Kanban...',
595
+ 'settings.testOk': '✅ 测试消息已成功发送至 Telegram!', 'settings.testFail': '❌ 发送失败: {error}',
596
+ 'settings.kanbanOk': '✅ Kanban 卡片创建成功', 'settings.kanbanFail': '❌ {error}',
597
597
  'settings.save': '保存设置',
598
598
  'settings.saved': '✓ 已保存',
599
599
  'settings.saveError': '保存失败: {error}',
@@ -618,11 +618,9 @@ window.__ModuleLoader__.load({
618
618
  'confirm.delete': '确定删除该任务吗?',
619
619
  'form.requireApprovalLabel': '执行前需人工确认审批',
620
620
  'form.requireApprovalHint': '开启后,任务执行前将挂起并等待授权用户批准提议 (#318)。',
621
- 'actions.approve': '批准',
622
- 'actions.reject': '拒绝',
621
+ 'actions.approve': '批准', 'actions.reject': '拒绝',
623
622
  'approval.waiting': '等待人工审批',
624
- 'approval.approved': '已批准',
625
- 'approval.rejected': '已拒绝',
623
+ 'approval.approved': '已批准', 'approval.rejected': '已拒绝',
626
624
  'recipe.dependenciesMissing': '缺少必要集成服务或凭据',
627
625
  'list.loadErrorPrefix': '加载任务列表失败'
628
626
  }
@@ -890,11 +888,6 @@ window.__ModuleLoader__.load({
890
888
  const STYLE_HEAL_KEY = '__dshCronStyleHeal';
891
889
  const STYLE_HEAL_INTERVAL_MS = 2000;
892
890
 
893
- /**
894
- * Keep the stylesheet attached: watch <head> (and <html>, which catches a
895
- * replaced <head>) and re-attach on demand, with a slow interval as the
896
- * backstop. heal() is idempotent and writes nothing while healthy.
897
- */
898
891
  function startStyleSelfHeal() {
899
892
  if (typeof document === 'undefined') return () => {};
900
893
  if (typeof window !== 'undefined' && typeof window[STYLE_HEAL_KEY] === 'function') {
@@ -997,7 +990,6 @@ window.__ModuleLoader__.load({
997
990
  const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
998
991
  const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
999
992
 
1000
- /** Collect every delivery key the settings endpoints may return. */
1001
993
  function parseEnvText(text) {
1002
994
  const env = {};
1003
995
  for (const rawLine of String(text || '').split('\n')) {
@@ -1091,15 +1083,10 @@ window.__ModuleLoader__.load({
1091
1083
  if (status === 'skipped') return T_KEY(t, 'history.statusSkipped');
1092
1084
  if (status === 'missed') return T_KEY(t, 'history.statusMissed');
1093
1085
  if (status === 'timeout') return T_KEY(t, 'history.statusTimeout');
1086
+ if (status === 'uncertain') return T_KEY(t, 'history.statusUncertain');
1094
1087
  return T_KEY(t, 'history.statusFailed');
1095
1088
  }
1096
1089
 
1097
- /**
1098
- * A config-declared task is owned by the profile config file (#50): the
1099
- * panel shows it, but editing, pausing or deleting it here would be undone
1100
- * at the next start, so the row keeps the source label instead of the
1101
- * actions that cannot stick.
1102
- */
1103
1090
  function isConfigManaged(task) {
1104
1091
  return Boolean(task) && task.managedBy === 'config';
1105
1092
  }
@@ -1107,10 +1094,6 @@ window.__ModuleLoader__.load({
1107
1094
  return (typeof t === 'function' ? t : translate)(key, vars);
1108
1095
  }
1109
1096
 
1110
- /**
1111
- * Issue #307: universal fetch with AbortController timeout (default 15s).
1112
- * Connects with caller signal if supplied and fails closed on timeouts.
1113
- */
1114
1097
  const fetchWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1115
1098
  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
1116
1099
  const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
@@ -1139,9 +1122,6 @@ window.__ModuleLoader__.load({
1139
1122
  }
1140
1123
  };
1141
1124
 
1142
- /**
1143
- * Issue #307: universal JSON fetch helper with timeout and aborted body protection.
1144
- */
1145
1125
  const fetchJsonWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
1146
1126
  const res = await fetchWithTimeout(url, options, timeoutMs);
1147
1127
  let data = {};
@@ -1166,11 +1146,6 @@ window.__ModuleLoader__.load({
1166
1146
  return out;
1167
1147
  }
1168
1148
 
1169
- /**
1170
- * Shared delivery form used by both the task panel modal and the plugin
1171
- * settings card, so a new channel is added in exactly one place.
1172
- * Sections collapse via the canonical aria-expanded head contract.
1173
- */
1174
1149
  function renderDeliverySettings({ T, values, onChange, onChannelTemplate, sections, onToggleSection }) {
1175
1150
  const val = (key) => (values[key] === undefined || values[key] === null ? '' : String(values[key]));
1176
1151
  const field = (f) => React.createElement('div', { className: 'dsh-cron-form-group', key: f.key, style: { marginBottom: '10px' } },
@@ -1247,7 +1222,6 @@ window.__ModuleLoader__.load({
1247
1222
  ];
1248
1223
  }
1249
1224
 
1250
- /** Parse a KEY=VALUE textarea into the task env object (#38). */
1251
1225
  function CronScreen(props) {
1252
1226
  const { ctx, toggle, onClose } = props;
1253
1227
  const t = props.t || translate;
@@ -1460,7 +1434,6 @@ window.__ModuleLoader__.load({
1460
1434
  return () => window.removeEventListener('keydown', onKey);
1461
1435
  }, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
1462
1436
 
1463
- /** #40: facet filtering happens client-side over the loaded list. */
1464
1437
  const visibleTasks = React.useMemo(() => { let list = tasks;
1465
1438
  if (filterType) list = list.filter((x) => (x.type || 'llm') === filterType);
1466
1439
  if (filterModel) list = list.filter((x) => String(x.model || '').split('/').pop() === filterModel);
@@ -1491,7 +1464,6 @@ window.__ModuleLoader__.load({
1491
1464
  setFilterChannel('');
1492
1465
  };
1493
1466
 
1494
- /** #34: scroll to and flash the task the sidebar list asked for. */
1495
1467
  React.useEffect(() => {
1496
1468
  if (!pendingTaskHighlight || tasks.length === 0) return;
1497
1469
  const id = pendingTaskHighlight;
@@ -1718,7 +1690,6 @@ window.__ModuleLoader__.load({
1718
1690
  }
1719
1691
  };
1720
1692
 
1721
- /** #41: duplicate a task; the server copies config, resets run state (#41). */
1722
1693
  const handleDryRun = async (taskId) => {
1723
1694
  try {
1724
1695
  const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/dry-run', { method: 'POST' });
@@ -1777,10 +1748,8 @@ window.__ModuleLoader__.load({
1777
1748
  }
1778
1749
  };
1779
1750
 
1780
- /** A task id must not shadow a collection route (#42 review finding). */
1781
1751
  const RESERVED_TASK_IDS = ['export', 'import'];
1782
1752
 
1783
- /** Import needs the script-confirm header when the file has code tasks. */
1784
1753
  const isCodeTaskForm = (x) => {
1785
1754
  if (!x || typeof x !== 'object') return false;
1786
1755
  if (CODE_FORM_TYPES.includes(x.type)) return true;
@@ -1797,7 +1766,6 @@ window.__ModuleLoader__.load({
1797
1766
  return headers;
1798
1767
  };
1799
1768
 
1800
- /** #42, #240: download the task configuration with honest disclosure and sanitized export choice. */
1801
1769
  const handleExport = async () => {
1802
1770
  setTransferBusy(true);
1803
1771
  try {
@@ -1827,7 +1795,6 @@ window.__ModuleLoader__.load({
1827
1795
  }
1828
1796
  };
1829
1797
 
1830
- /** #42: read the picked file, validate it server-side and show the plan. */
1831
1798
  const handleImportFile = async (e) => {
1832
1799
  const file = e.target.files && e.target.files[0];
1833
1800
  e.target.value = '';
@@ -1884,7 +1851,6 @@ window.__ModuleLoader__.load({
1884
1851
  }
1885
1852
  };
1886
1853
 
1887
- /** #49: tune an existing task in a dialogue instead of editing fields. */
1888
1854
  const handleTuneWithDsh = async (task) => {
1889
1855
  const lines = [
1890
1856
  `Tune the existing scheduled task "${task.title}" (id: ${task.id}).`,
@@ -2007,11 +1973,6 @@ window.__ModuleLoader__.load({
2007
1973
  }
2008
1974
  };
2009
1975
 
2010
- /**
2011
- * Update one delivery setting without dropping the rest of the object.
2012
- * `undefined` removes the key so the server default applies again; an
2013
- * empty string is kept so a text field can be cleared on purpose.
2014
- */
2015
1976
  const updateDelivery = (key, value) => {
2016
1977
  setSettingsDelivery((prev) => {
2017
1978
  const next = Object.assign({}, prev);
@@ -2021,7 +1982,6 @@ window.__ModuleLoader__.load({
2021
1982
  });
2022
1983
  };
2023
1984
 
2024
- /** Per-channel template overrides live in a nested map on the server. */
2025
1985
  const updateChannelTemplate = (channelId, value) => {
2026
1986
  setSettingsDelivery((prev) => {
2027
1987
  const templates = Object.assign({}, prev.channelTemplates || {});
@@ -2195,6 +2155,23 @@ window.__ModuleLoader__.load({
2195
2155
  }
2196
2156
  };
2197
2157
 
2158
+ const handleResolveUncertain = async (taskId, runId, resolution) => {
2159
+ try {
2160
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/resolve-uncertain', {
2161
+ method: 'POST',
2162
+ headers: { 'Content-Type': 'application/json' },
2163
+ body: JSON.stringify({ runId, resolution }),
2164
+ });
2165
+ if (data && data.ok) {
2166
+ await loadTaskHistory(taskId);
2167
+ } else {
2168
+ alert(data?.error || 'Failed to resolve uncertain run');
2169
+ }
2170
+ } catch (e) {
2171
+ alert(e.message || 'Error resolving uncertain run');
2172
+ }
2173
+ };
2174
+
2198
2175
  const loadTaskHistory = async (taskId) => {
2199
2176
  setHistoryLoading(true);
2200
2177
  try {
@@ -2828,6 +2805,41 @@ window.__ModuleLoader__.load({
2828
2805
  onClick: () => handleRetryDelivery(formId, run.id)
2829
2806
  }, retryingDeliveryId === run.id ? T_KEY(t, 'history.retryingDelivery') : T_KEY(t, 'history.retryDelivery'))
2830
2807
  ),
2808
+ run.artifacts && run.artifacts.length > 0 && React.createElement('div', {
2809
+ style: { display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '6px', alignItems: 'center' }
2810
+ },
2811
+ React.createElement('span', { className: 'dsh-cron-hint', style: { fontSize: '11px' } }, '📦 ' + T_KEY(t, 'history.artifacts') + ':'),
2812
+ run.artifacts.map((art) => React.createElement('a', {
2813
+ key: art.name || art.id,
2814
+ href: '/dsh-cron/runs/' + encodeURIComponent(run.id) + '/artifacts/' + encodeURIComponent(art.name) + '/preview',
2815
+ target: '_blank',
2816
+ rel: 'noopener noreferrer',
2817
+ className: 'dsh-cron-btn-secondary',
2818
+ style: { padding: '2px 8px', fontSize: '11px', textDecoration: 'none' },
2819
+ title: art.name + ' (' + Math.round((art.size || 0) / 1024 * 10) / 10 + ' KB)'
2820
+ }, art.name))
2821
+ ),
2822
+ run.status === 'uncertain' && React.createElement('div', {
2823
+ className: 'dsh-cron-status-banner',
2824
+ 'data-type': 'warning',
2825
+ style: { marginTop: '8px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }
2826
+ },
2827
+ React.createElement('span', null, '⚠️ ' + T_KEY(t, 'history.statusUncertainHint')),
2828
+ React.createElement('div', { style: { display: 'flex', gap: '6px' } },
2829
+ React.createElement('button', {
2830
+ type: 'button',
2831
+ className: 'dsh-cron-btn-secondary',
2832
+ style: { padding: '2px 8px', fontSize: '11px' },
2833
+ onClick: () => handleResolveUncertain(formId, run.id, 'completed')
2834
+ }, '✓ ' + T_KEY(t, 'history.resolveCompleted')),
2835
+ React.createElement('button', {
2836
+ type: 'button',
2837
+ className: 'dsh-cron-btn-secondary',
2838
+ style: { padding: '2px 8px', fontSize: '11px' },
2839
+ onClick: () => handleResolveUncertain(formId, run.id, 'failed')
2840
+ }, '✕ ' + T_KEY(t, 'history.resolveFailed'))
2841
+ )
2842
+ ),
2831
2843
  run.diagnosis && React.createElement('div', {
2832
2844
  className: 'dsh-cron-status-banner',
2833
2845
  'data-type': 'info',
@@ -3351,7 +3363,7 @@ window.__ModuleLoader__.load({
3351
3363
  React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.silentRuleHint'))
3352
3364
  )
3353
3365
  ),
3354
-
3366
+
3355
3367
  React.createElement('div', {
3356
3368
  style: {
3357
3369
  marginTop: '16px',
@@ -3485,7 +3497,6 @@ window.__ModuleLoader__.load({
3485
3497
  )
3486
3498
  ),
3487
3499
 
3488
-
3489
3500
  dryRunResult && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setDryRunResult(null) },
3490
3501
  React.createElement('div', {
3491
3502
  className: 'dsh-cron-modal',
@@ -3783,6 +3794,7 @@ window.__ModuleLoader__.load({
3783
3794
  const [notify, setNotify] = React.useState(false);
3784
3795
  const [onlyFailure, setOnlyFailure] = React.useState(false);
3785
3796
  const [llmActionsEnabled, setLlmActionsEnabled] = React.useState(false);
3797
+ const [enableDynamicCredentials, setEnableDynamicCredentials] = React.useState(false);
3786
3798
  const [kanbanUrl, setKanbanUrl] = React.useState('http://127.0.0.1:3000');
3787
3799
  const [delivery, setDelivery] = React.useState({});
3788
3800
  const [sections, setSections] = React.useState({ channels: true });
@@ -3863,6 +3875,7 @@ window.__ModuleLoader__.load({
3863
3875
  setNotify(Boolean(data.settings.notifyTelegram));
3864
3876
  setOnlyFailure(Boolean(data.settings.onlyOnFailure));
3865
3877
  setLlmActionsEnabled(Boolean(data.settings.llmActionsEnabled));
3878
+ setEnableDynamicCredentials(Boolean(data.settings.enableDynamicCredentials));
3866
3879
  setKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
3867
3880
  setDelivery(pickDeliverySettings(data.settings));
3868
3881
  setHasDefault(Boolean(data.settings.hasDefaultCredentials));
@@ -3890,6 +3903,7 @@ window.__ModuleLoader__.load({
3890
3903
  notifyTelegram: notify,
3891
3904
  onlyOnFailure: onlyFailure,
3892
3905
  llmActionsEnabled: llmActionsEnabled,
3906
+ enableDynamicCredentials: enableDynamicCredentials,
3893
3907
  kanbanBaseUrl: kanbanUrl
3894
3908
  }, delivery))
3895
3909
  });
@@ -4031,6 +4045,14 @@ window.__ModuleLoader__.load({
4031
4045
  onChange: (e) => setLlmActionsEnabled(e.target.checked)
4032
4046
  }),
4033
4047
  React.createElement('span', null, 'Enable Structured LLM Actions (trigger tasks, notify, create issues)')
4048
+ ),
4049
+ React.createElement('label', { className: 'dsh-cron-checkbox-row' },
4050
+ React.createElement('input', {
4051
+ type: 'checkbox',
4052
+ checked: enableDynamicCredentials,
4053
+ onChange: (e) => setEnableDynamicCredentials(e.target.checked)
4054
+ }),
4055
+ React.createElement('span', null, T_KEY(t, 'settings.dynamicCredentials'))
4034
4056
  )
4035
4057
  ),
4036
4058
  ...renderDeliverySettings({
@@ -4167,18 +4189,9 @@ window.__ModuleLoader__.load({
4167
4189
  return entry;
4168
4190
  }
4169
4191
 
4170
- /**
4171
- * Collapsible list of active jobs under the sidebar entry (#34).
4172
- *
4173
- * Vanilla DOM like the entry itself: the sidebar is not a React root here.
4174
- * Collapsed by default, state persisted, capped at a few rows with an
4175
- * "and N more" line so a busy schedule cannot push the rest of the sidebar
4176
- * off screen. Clicking a row opens the panel and highlights that task.
4177
- */
4178
4192
  const SIDEBAR_JOBS_KEY = 'dsh-cron-sidebar-open';
4179
4193
  const SIDEBAR_JOBS_MAX_ROWS = 5;
4180
4194
 
4181
- /** Sidebar job row: title plus next run time or the live running state. */
4182
4195
  function buildSidebarJobRow(state, task) {
4183
4196
  const row = document.createElement('button');
4184
4197
  row.type = 'button';
@@ -4274,7 +4287,6 @@ window.__ModuleLoader__.load({
4274
4287
  return root;
4275
4288
  }
4276
4289
 
4277
- /** Keep the section mounted under the sidebar entry across re-renders. */
4278
4290
  function placeSidebarJobs(state, label) {
4279
4291
  if (state.root !== undefined && state.root.isConnected) return;
4280
4292
  const entry = document.querySelector('[' + ENTRY_ATTR + ']');
@@ -4290,14 +4302,6 @@ window.__ModuleLoader__.load({
4290
4302
  applySidebarJobsOpen(state);
4291
4303
  }
4292
4304
 
4293
- /**
4294
- * Collapsible list of active jobs under the sidebar entry (#34).
4295
- *
4296
- * Vanilla DOM like the entry itself: the sidebar is not a React root here.
4297
- * Collapsed by default, state persisted, capped at a few rows with an
4298
- * "and N more" line so a busy schedule cannot push the rest of the sidebar
4299
- * off screen. Clicking a row opens the panel and highlights that task.
4300
- */
4301
4305
  function mountSidebarJobs(toggle, label, openTaskSignal) {
4302
4306
  if (typeof document === 'undefined') return () => {};
4303
4307
  const state = { toggle, openTaskSignal, root: undefined, listEl: undefined, headEl: undefined, open: false };
@@ -4437,12 +4441,6 @@ window.__ModuleLoader__.load({
4437
4441
  };
4438
4442
  }
4439
4443
 
4440
- /**
4441
- * Register a card into a DSH mount point. Mount points are populated via
4442
- * ctx.slots.inject(mount, cb) — a direct slots.register does not throw but
4443
- * also never appears in the target surface (settings plugins tab, header),
4444
- * so the inject contract is mandatory (#85).
4445
- */
4446
4444
  function registerIntoMount(ctx, mountName, entry, component) {
4447
4445
  if (!ctx || !ctx.slots || typeof ctx.slots.inject !== 'function') return false;
4448
4446
  try {
@@ -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
+ }
@@ -25,6 +25,11 @@ export function buildPromptContext(task = {}, options = {}) {
25
25
  const prevDuration = formatDuration(options.prevDurationMs);
26
26
  const prevCost = options.prevCostUsd != null ? `$${Number(options.prevCostUsd || 0).toFixed(4)}` : '';
27
27
 
28
+ const filePath = options.filePath != null ? String(options.filePath) : '';
29
+ const fileName = options.fileName != null ? String(options.fileName) : '';
30
+ const fileSize = options.fileSize != null ? String(options.fileSize) : '';
31
+ const fileEvent = options.fileEvent != null ? String(options.fileEvent) : '';
32
+
28
33
  return {
29
34
  // Chaining context
30
35
  'prev.output': prevOutput,
@@ -39,6 +44,20 @@ export function buildPromptContext(task = {}, options = {}) {
39
44
  'prev.cost': prevCost,
40
45
  'prev_cost': prevCost,
41
46
 
47
+ // File watcher context (#327)
48
+ 'filePath': filePath,
49
+ 'file.path': filePath,
50
+ 'file_path': filePath,
51
+ 'fileName': fileName,
52
+ 'file.name': fileName,
53
+ 'file_name': fileName,
54
+ 'fileSize': fileSize,
55
+ 'file.size': fileSize,
56
+ 'file_size': fileSize,
57
+ 'fileEvent': fileEvent,
58
+ 'file.event': fileEvent,
59
+ 'file_event': fileEvent,
60
+
42
61
  // Time context
43
62
  'date': formatDate(now),
44
63
  'time': formatTime(now),