@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/api.js +60 -0
- package/lib/approval.js +143 -0
- package/lib/artifacts.js +200 -0
- package/lib/chain-checkpoints.js +125 -0
- package/lib/client.js +127 -76
- package/lib/cron-tool.js +4 -0
- package/lib/durable-journal.js +188 -0
- package/lib/global-budgets.js +137 -0
- package/lib/recipes-catalog.js +107 -0
- package/lib/routes.js +87 -1
- package/lib/scheduler-execution.js +91 -1
- package/lib/scheduler.js +20 -0
- package/lib/settings.js +5 -0
- package/lib/store.js +50 -1
- package/lib/task-create.js +1 -0
- package/lib/task-patch.js +3 -0
- package/lib/task-transfer.js +1 -0
- package/package.json +1 -1
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.
|
|
144
|
-
'history.
|
|
145
|
-
'history.
|
|
146
|
-
'history.
|
|
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
|
+
}
|