@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/api.js +24 -0
- package/lib/artifacts.js +200 -0
- package/lib/chain-checkpoints.js +125 -0
- package/lib/client.js +89 -91
- package/lib/durable-journal.js +188 -0
- package/lib/prompt-interpolation.js +19 -0
- package/lib/routes.js +84 -0
- package/lib/runner.js +99 -1
- package/lib/scheduler-execution.js +58 -0
- package/lib/scheduler-lifecycle.js +6 -0
- package/lib/scheduler.js +35 -0
- package/lib/secrets.js +114 -0
- package/lib/settings.js +3 -1
- package/lib/store.js +50 -1
- package/lib/task-create.js +17 -0
- package/lib/task-patch.js +4 -0
- package/lib/task-transfer.js +1 -0
- package/lib/watcher.js +249 -0
- package/package.json +1 -1
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.
|
|
144
|
-
'history.
|
|
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.
|
|
290
|
-
'settings.
|
|
291
|
-
'settings.
|
|
292
|
-
'settings.checkingKanban': 'Checking Kanban...',
|
|
293
|
-
'settings.testOk': '✅ Test message delivered to Telegram!',
|
|
294
|
-
'settings.
|
|
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.
|
|
590
|
-
'settings.
|
|
591
|
-
'settings.
|
|
592
|
-
'settings.checkingKanban': '正在连接 Kanban...',
|
|
593
|
-
'settings.testOk': '✅ 测试消息已成功发送至 Telegram!',
|
|
594
|
-
'settings.
|
|
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),
|