@goodandready/dsh-cron 0.3.24 → 0.3.26
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-actions.js +1 -317
- package/lib/api-collection.js +1 -250
- package/lib/api-helpers.js +1 -100
- package/lib/api-import-export.js +1 -155
- package/lib/api-item.js +1 -190
- package/lib/api-task-webhook.js +1 -247
- package/lib/api-webhook.js +1 -183
- package/lib/api.js +1 -178
- package/lib/artifacts.js +1 -200
- package/lib/chain-checkpoints.js +1 -223
- package/lib/client-src/00-open.js +62 -0
- package/lib/client-src/10-locales.js +484 -0
- package/lib/client-src/20-styles.js +64 -0
- package/lib/client-src/30-utils.js +248 -0
- package/lib/client-src/40-delivery-settings.js +96 -0
- package/lib/client-src/45-dag-graph-view.js +287 -0
- package/lib/client-src/46-live-logs-modal.js +108 -0
- package/lib/client-src/47-analytics-digest-modal.js +166 -0
- package/lib/client-src/48-timeline-heatmap-view.js +176 -0
- package/lib/client-src/50-cron-screen-state.js +447 -0
- package/lib/client-src/52-cron-screen-actions.js +330 -0
- package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
- package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
- package/lib/client-src/55-cron-screen-view.js +378 -0
- package/lib/client-src/60-modal-task-form-head.js +580 -0
- package/lib/client-src/62-modal-task-form-tail.js +466 -0
- package/lib/client-src/65-modal-dialogs.js +350 -0
- package/lib/client-src/70-settings-card.js +420 -0
- package/lib/client-src/80-sidebar-jobs.js +200 -0
- package/lib/client-src/90-close.js +200 -0
- package/lib/client.js +48 -3
- package/lib/commands.js +200 -0
- package/lib/durable-journal.js +1 -188
- package/lib/external-api.js +1 -74
- package/lib/index.js +17 -0
- package/lib/log-streamer.js +10 -2
- package/lib/metrics.js +1 -125
- package/lib/routes/api-actions.js +317 -0
- package/lib/routes/api-collection.js +250 -0
- package/lib/routes/api-helpers.js +100 -0
- package/lib/routes/api-import-export.js +155 -0
- package/lib/routes/api-item.js +191 -0
- package/lib/routes/api-task-webhook.js +247 -0
- package/lib/routes/api-webhook.js +183 -0
- package/lib/routes/api.js +178 -0
- package/lib/routes/external-api.js +74 -0
- package/lib/routes/index.js +11 -0
- package/lib/routes/metrics.js +125 -0
- package/lib/routes/routes.js +549 -0
- package/lib/routes.js +1 -549
- package/lib/runner-external.js +1 -352
- package/lib/runner-worktree.js +1 -90
- package/lib/runner.js +1 -881
- package/lib/runtimes/index.js +4 -0
- package/lib/runtimes/runner-external.js +352 -0
- package/lib/runtimes/runner-worktree.js +90 -0
- package/lib/runtimes/runner.js +881 -0
- package/lib/runtimes/runtimes.js +198 -0
- package/lib/runtimes.js +1 -198
- package/lib/scheduler/index.js +6 -0
- package/lib/scheduler/scheduler-assists.js +142 -0
- package/lib/scheduler/scheduler-completion.js +417 -0
- package/lib/scheduler/scheduler-execution.js +360 -0
- package/lib/scheduler/scheduler-lifecycle.js +157 -0
- package/lib/scheduler/scheduler-preflight.js +195 -0
- package/lib/scheduler/scheduler.js +751 -0
- package/lib/scheduler-assists.js +1 -142
- package/lib/scheduler-completion.js +1 -417
- package/lib/scheduler-execution.js +1 -360
- package/lib/scheduler-lifecycle.js +1 -157
- package/lib/scheduler-preflight.js +1 -195
- package/lib/scheduler.js +1 -751
- package/lib/store/artifacts.js +200 -0
- package/lib/store/chain-checkpoints.js +223 -0
- package/lib/store/durable-journal.js +188 -0
- package/lib/store/index.js +6 -0
- package/lib/store/store.js +1179 -0
- package/lib/store/task-schema.js +180 -0
- package/lib/store/task-transfer.js +310 -0
- package/lib/store.js +2 -1179
- package/lib/task-schema.js +1 -180
- package/lib/task-transfer.js +1 -310
- package/lib/tui-settings.js +157 -0
- package/package.json +6 -3
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
function createErrorBoundary() {
|
|
2
|
+
if (!React || typeof React.Component !== 'function') {
|
|
3
|
+
return function NoopBoundary(props) { return (props && props.children) || null; };
|
|
4
|
+
}
|
|
5
|
+
return class ErrorBoundary extends React.Component {
|
|
6
|
+
constructor(props) {
|
|
7
|
+
super(props);
|
|
8
|
+
this.state = { hasError: false, error: null };
|
|
9
|
+
}
|
|
10
|
+
static getDerivedStateFromError(error) {
|
|
11
|
+
return { hasError: true, error };
|
|
12
|
+
}
|
|
13
|
+
componentDidCatch(error, errorInfo) {
|
|
14
|
+
console.error('[dsh-cron] React error:', error, errorInfo);
|
|
15
|
+
}
|
|
16
|
+
render() {
|
|
17
|
+
if (this.state.hasError) {
|
|
18
|
+
return React.createElement('div', { className: 'dsh-cron-status-banner', 'data-type': 'error', style: { margin: '12px 0' } },
|
|
19
|
+
React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ Cron UI error:'),
|
|
20
|
+
React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String((this.state.error && this.state.error.message) || this.state.error)),
|
|
21
|
+
React.createElement('button', {
|
|
22
|
+
type: 'button',
|
|
23
|
+
className: 'dsh-cron-btn-secondary',
|
|
24
|
+
style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
|
|
25
|
+
onClick: () => this.setState({ hasError: false, error: null })
|
|
26
|
+
}, 'Retry')
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return (this.props && this.props.children) || null;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const ErrorBoundary = createErrorBoundary();
|
|
34
|
+
|
|
35
|
+
const AGENT_FORM_TYPES = ['llm', 'skill', 'workflow'];
|
|
36
|
+
const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
|
|
37
|
+
|
|
38
|
+
// Delivery channels (#26) — must stay in sync with lib/channels.js.
|
|
39
|
+
const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
|
|
40
|
+
const CHANNEL_LABEL_KEYS = {
|
|
41
|
+
telegram: 'form.channelTelegram',
|
|
42
|
+
kanban: 'form.channelKanban',
|
|
43
|
+
discord: 'form.channelDiscord',
|
|
44
|
+
slack: 'form.channelSlack',
|
|
45
|
+
ntfy: 'form.channelNtfy',
|
|
46
|
+
bark: 'form.channelBark',
|
|
47
|
+
pushplus: 'form.channelPushplus',
|
|
48
|
+
tts: 'form.channelTts',
|
|
49
|
+
gitea: 'form.channelGitea',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Non-secret delivery settings shown in the settings panel. Secrets are
|
|
53
|
+
// referenced by credential name only (#51) — never typed as values.
|
|
54
|
+
const DELIVERY_SETTING_FIELDS = [
|
|
55
|
+
{ key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
|
|
56
|
+
{ key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
|
|
57
|
+
{ key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
|
|
58
|
+
{ key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
|
|
59
|
+
// The external API is the one surface where a typed secret is the point:
|
|
60
|
+
// a CI job cannot present a credential reference (#54).
|
|
61
|
+
{ key: 'apiToken', labelKey: 'settings.apiTokenLabel', placeholderKey: 'settings.apiTokenPlaceholder', group: 'secrets' },
|
|
62
|
+
{ key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
|
|
63
|
+
{ key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
|
|
64
|
+
{ key: 'ntfyUrl', labelKey: 'settings.ntfyUrlLabel', group: 'channels' },
|
|
65
|
+
{ key: 'ntfyTopic', labelKey: 'settings.ntfyTopicLabel', group: 'channels' },
|
|
66
|
+
{ key: 'barkServerUrl', labelKey: 'settings.barkServerLabel', group: 'channels' },
|
|
67
|
+
{ key: 'barkKey', labelKey: 'settings.barkKeyLabel', group: 'channels' },
|
|
68
|
+
{ key: 'pushplusUrl', labelKey: 'settings.pushplusUrlLabel', group: 'channels' },
|
|
69
|
+
{ key: 'ttsBaseUrl', labelKey: 'settings.ttsBaseUrlLabel', group: 'channels' },
|
|
70
|
+
{ key: 'giteaBaseUrl', labelKey: 'settings.giteaBaseUrlLabel', group: 'channels' },
|
|
71
|
+
{ key: 'giteaRepo', labelKey: 'settings.giteaRepoLabel', group: 'channels' },
|
|
72
|
+
{ key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true, defaultValue: 15000, min: 1000 },
|
|
73
|
+
{ key: 'silentRuleModel', labelKey: 'settings.silentRuleModelLabel', group: 'automation' },
|
|
74
|
+
{ key: 'inspectorModel', labelKey: 'settings.inspectorModelLabel', group: 'automation' },
|
|
75
|
+
];
|
|
76
|
+
const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
|
|
77
|
+
// The global template plus the per-channel override map (server model).
|
|
78
|
+
const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
|
|
79
|
+
|
|
80
|
+
/** Collect every delivery key the settings endpoints may return. */
|
|
81
|
+
function parseEnvText(text) {
|
|
82
|
+
const env = {};
|
|
83
|
+
for (const rawLine of String(text || '').split('\n')) {
|
|
84
|
+
const line = rawLine.trim();
|
|
85
|
+
if (!line || line.startsWith('#')) continue;
|
|
86
|
+
const idx = line.indexOf('=');
|
|
87
|
+
if (idx <= 0) continue;
|
|
88
|
+
const key = line.slice(0, idx).trim();
|
|
89
|
+
const value = line.slice(idx + 1).trim();
|
|
90
|
+
if (key) env[key] = value;
|
|
91
|
+
}
|
|
92
|
+
return Object.keys(env).length ? env : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatEnvText(env) {
|
|
96
|
+
if (!env || typeof env !== 'object') return '';
|
|
97
|
+
return Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Status colors: core semantic state tokens first (same set dsh-clinebot
|
|
101
|
+
// uses), plugin fallbacks as a single point of change (#105).
|
|
102
|
+
function createToggle() {
|
|
103
|
+
let open = false;
|
|
104
|
+
const listeners = new Set();
|
|
105
|
+
return {
|
|
106
|
+
isOpen: () => open,
|
|
107
|
+
set: (v) => {
|
|
108
|
+
const next = !!v;
|
|
109
|
+
if (open === next) {
|
|
110
|
+
for (const fn of listeners) fn(open);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
open = next;
|
|
114
|
+
for (const fn of listeners) fn(open);
|
|
115
|
+
},
|
|
116
|
+
toggle: () => {
|
|
117
|
+
open = !open;
|
|
118
|
+
for (const fn of listeners) fn(open);
|
|
119
|
+
},
|
|
120
|
+
subscribe: (fn) => {
|
|
121
|
+
listeners.add(fn);
|
|
122
|
+
return () => listeners.delete(fn);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function openSession(ctx, sessionId) {
|
|
128
|
+
if (!sessionId) return false;
|
|
129
|
+
const getSessions = () => (ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : (ctx && ctx.sessions));
|
|
130
|
+
let sessions = getSessions();
|
|
131
|
+
|
|
132
|
+
// 1. Try sessions.open(sessionId) immediately
|
|
133
|
+
if (sessions && typeof sessions.open === 'function') {
|
|
134
|
+
try {
|
|
135
|
+
sessions.open(sessionId);
|
|
136
|
+
return true;
|
|
137
|
+
} catch (err) {
|
|
138
|
+
/* sessions.open threw synchronously */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 2. If the session is not in manager.summaries yet, refresh from the backend
|
|
143
|
+
if (sessions && typeof sessions.refresh === 'function') {
|
|
144
|
+
try {
|
|
145
|
+
await sessions.refresh();
|
|
146
|
+
sessions.open(sessionId);
|
|
147
|
+
return true;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
/* sessions.refresh threw */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 3. Async retries until the WebSocket/SSE event adds the session to the list
|
|
154
|
+
for (let i = 0; i < 15; i++) {
|
|
155
|
+
await new Promise(resolve => setTimeout(resolve, 120));
|
|
156
|
+
sessions = getSessions();
|
|
157
|
+
if (sessions && typeof sessions.open === 'function') {
|
|
158
|
+
try {
|
|
159
|
+
sessions.open(sessionId);
|
|
160
|
+
return true;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
/* retry polling sessions.open threw */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// DOM fallback: click the session in the sidebar if it has rendered
|
|
166
|
+
if (typeof document !== 'undefined') {
|
|
167
|
+
const domItem = document.querySelector('[data-session-id="' + sessionId + '"], [role="treeitem"][data-id="' + sessionId + '"], [data-id="' + sessionId + '"]');
|
|
168
|
+
if (domItem) {
|
|
169
|
+
domItem.click();
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
console.warn('[dsh-cron] could not open session in workspace:', sessionId);
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function statusLabel(t, status) {
|
|
179
|
+
if (status === 'success') return T_KEY(t, 'history.statusSuccess');
|
|
180
|
+
if (status === 'validation_failed') return T_KEY(t, 'history.statusValidationFailed');
|
|
181
|
+
if (status === 'skipped') return T_KEY(t, 'history.statusSkipped');
|
|
182
|
+
if (status === 'missed') return T_KEY(t, 'history.statusMissed');
|
|
183
|
+
if (status === 'timeout') return T_KEY(t, 'history.statusTimeout');
|
|
184
|
+
if (status === 'uncertain') return T_KEY(t, 'history.statusUncertain');
|
|
185
|
+
return T_KEY(t, 'history.statusFailed');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A config-declared task is owned by the profile config file (#50): the
|
|
190
|
+
* panel shows it, but editing, pausing or deleting it here would be undone
|
|
191
|
+
* at the next start, so the row keeps the source label instead of the
|
|
192
|
+
* actions that cannot stick.
|
|
193
|
+
*/
|
|
194
|
+
function isConfigManaged(task) {
|
|
195
|
+
return Boolean(task) && task.managedBy === 'config';
|
|
196
|
+
}
|
|
197
|
+
function T_KEY(t, key, vars) {
|
|
198
|
+
return (typeof t === 'function' ? t : translate)(key, vars);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Issue #307: universal fetch with AbortController timeout (default 15s).
|
|
203
|
+
* Connects with caller signal if supplied and fails closed on timeouts.
|
|
204
|
+
*/
|
|
205
|
+
const fetchWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
|
|
206
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
207
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
208
|
+
if (options && options.signal && controller) {
|
|
209
|
+
if (options.signal.aborted) {
|
|
210
|
+
controller.abort();
|
|
211
|
+
} else {
|
|
212
|
+
options.signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const res = await fetch(url, {
|
|
217
|
+
...options,
|
|
218
|
+
signal: controller ? controller.signal : (options ? options.signal : undefined),
|
|
219
|
+
});
|
|
220
|
+
return res;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (err && (err.name === 'AbortError' || controller?.signal?.aborted)) {
|
|
223
|
+
const timeoutErr = new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
224
|
+
timeoutErr.isTimeout = true;
|
|
225
|
+
throw timeoutErr;
|
|
226
|
+
}
|
|
227
|
+
throw err;
|
|
228
|
+
} finally {
|
|
229
|
+
if (timer) clearTimeout(timer);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Issue #307: universal JSON fetch helper with timeout and aborted body protection.
|
|
235
|
+
*/
|
|
236
|
+
const fetchJsonWithTimeout = async (url, options = {}, timeoutMs = 15000) => {
|
|
237
|
+
const res = await fetchWithTimeout(url, options, timeoutMs);
|
|
238
|
+
let data = {};
|
|
239
|
+
try {
|
|
240
|
+
data = await res.json();
|
|
241
|
+
} catch (jsonErr) {
|
|
242
|
+
if (jsonErr && jsonErr.name === 'AbortError') {
|
|
243
|
+
throw jsonErr;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return { res, data };
|
|
247
|
+
};
|
|
248
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
function pickDeliverySettings(source) {
|
|
2
|
+
const out = {};
|
|
3
|
+
for (const key of DELIVERY_SETTING_KEYS.concat(TEMPLATE_SETTING_KEYS)) {
|
|
4
|
+
if (source && source[key] !== undefined && source[key] !== null) out[key] = source[key];
|
|
5
|
+
}
|
|
6
|
+
if (out.channelTemplates && typeof out.channelTemplates === 'object') {
|
|
7
|
+
out.channelTemplates = Object.assign({}, out.channelTemplates);
|
|
8
|
+
}
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Shared delivery form used by both the task panel modal and the plugin
|
|
14
|
+
* settings card, so a new channel is added in exactly one place.
|
|
15
|
+
* Sections collapse via the canonical aria-expanded head contract.
|
|
16
|
+
*/
|
|
17
|
+
function renderDeliverySettings({ T, values, onChange, onChannelTemplate, sections, onToggleSection }) {
|
|
18
|
+
const val = (key) => (values[key] === undefined || values[key] === null ? '' : String(values[key]));
|
|
19
|
+
const field = (f) => React.createElement('div', { className: 'dsh-cron-form-group', key: f.key, style: { marginBottom: '10px' } },
|
|
20
|
+
React.createElement('label', null, T(f.labelKey)),
|
|
21
|
+
React.createElement('input', f.numeric ? {
|
|
22
|
+
type: 'number',
|
|
23
|
+
min: f.min !== undefined ? f.min : 1000,
|
|
24
|
+
step: 1000,
|
|
25
|
+
value: val(f.key),
|
|
26
|
+
placeholder: String(f.defaultValue !== undefined ? f.defaultValue : 15000),
|
|
27
|
+
// #115: clearing the field must send the explicit default. Deleting the
|
|
28
|
+
// key would leave the previously stored value in place (the settings
|
|
29
|
+
// scope skips absent keys), so the field would silently keep 1 ms.
|
|
30
|
+
onChange: (e) => onChange(f.key, e.target.value === ''
|
|
31
|
+
? (f.defaultValue !== undefined ? f.defaultValue : 15000)
|
|
32
|
+
: Number(e.target.value)),
|
|
33
|
+
} : {
|
|
34
|
+
type: 'text',
|
|
35
|
+
value: val(f.key),
|
|
36
|
+
placeholder: f.placeholderKey ? T(f.placeholderKey) : '',
|
|
37
|
+
onChange: (e) => onChange(f.key, e.target.value)
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
const section = (key, titleKey, children) => {
|
|
41
|
+
const open = Boolean(sections[key]);
|
|
42
|
+
return React.createElement('div', { className: 'dsh-cron-section', key },
|
|
43
|
+
React.createElement('button', {
|
|
44
|
+
type: 'button',
|
|
45
|
+
className: 'dsh-cron-section-head',
|
|
46
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
47
|
+
onClick: () => onToggleSection(key)
|
|
48
|
+
},
|
|
49
|
+
React.createElement('span', null, T(titleKey)),
|
|
50
|
+
React.createElement('span', { className: 'dsh-cron-section-chevron', 'data-open': open ? 'true' : 'false' }, '▾')
|
|
51
|
+
),
|
|
52
|
+
open && React.createElement('div', { className: 'dsh-cron-section-body' }, children)
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return [
|
|
57
|
+
section('secrets', 'settings.secretsSection', [
|
|
58
|
+
React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.secretsHint')),
|
|
59
|
+
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'secrets').map(field),
|
|
60
|
+
]),
|
|
61
|
+
section('channels', 'settings.deliverySection', [
|
|
62
|
+
React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
|
|
63
|
+
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'channels').map(field)
|
|
64
|
+
),
|
|
65
|
+
]),
|
|
66
|
+
section('automation', 'settings.automationSection', [
|
|
67
|
+
React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.automationHint')),
|
|
68
|
+
React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
|
|
69
|
+
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'automation').map(field)
|
|
70
|
+
),
|
|
71
|
+
]),
|
|
72
|
+
section('templates', 'settings.templatesSection', [
|
|
73
|
+
React.createElement('div', { className: 'dsh-cron-form-group', key: 'global', style: { marginBottom: '10px' } },
|
|
74
|
+
React.createElement('label', null, T('settings.templateLabel')),
|
|
75
|
+
React.createElement('input', {
|
|
76
|
+
type: 'text',
|
|
77
|
+
value: val('template'),
|
|
78
|
+
onChange: (e) => onChange('template', e.target.value)
|
|
79
|
+
}),
|
|
80
|
+
React.createElement('div', { className: 'dsh-cron-hint' }, T('settings.templateHint'))
|
|
81
|
+
),
|
|
82
|
+
React.createElement('div', { className: 'dsh-cron-form-row', key: 'perchannel' },
|
|
83
|
+
...DELIVERY_CHANNELS.map((id) => React.createElement('div', { className: 'dsh-cron-form-group', key: id, style: { marginBottom: '10px' } },
|
|
84
|
+
React.createElement('label', null, T('settings.channelTemplateLabel', { channel: T(CHANNEL_LABEL_KEYS[id]) })),
|
|
85
|
+
React.createElement('input', {
|
|
86
|
+
type: 'text',
|
|
87
|
+
value: (values.channelTemplates && values.channelTemplates[id]) || '',
|
|
88
|
+
onChange: (e) => onChannelTemplate(id, e.target.value)
|
|
89
|
+
})
|
|
90
|
+
))
|
|
91
|
+
),
|
|
92
|
+
]),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Parse a KEY=VALUE textarea into the task env object (#38). */
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Visual Chain Graph & Checkpoint Inspector (#334)
|
|
3
|
+
* Pure CSS/SVG layout showing DAG steps, statuses, checkpoints and 1-click retry.
|
|
4
|
+
*/
|
|
5
|
+
function parseChainTargets(raw) {
|
|
6
|
+
if (!raw) return [];
|
|
7
|
+
if (Array.isArray(raw)) return raw.filter((x) => typeof x === 'string' && x.trim().length > 0);
|
|
8
|
+
if (typeof raw === 'string') return raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
const sanitizeDependsOn = parseChainTargets;
|
|
12
|
+
const sanitizeJoinPolicy = (raw) => (raw === 'any_success' ? 'any_success' : 'all_success');
|
|
13
|
+
|
|
14
|
+
function buildTaskGraphModel(tasks = [], snapshots = [], focusTaskId = null) {
|
|
15
|
+
const taskMap = new Map();
|
|
16
|
+
for (const t of tasks) if (t?.id) taskMap.set(t.id, t);
|
|
17
|
+
const snapMap = new Map();
|
|
18
|
+
for (const s of snapshots) if (s?.stepTaskId) snapMap.set(s.stepTaskId, s);
|
|
19
|
+
const nodes = [], edges = [];
|
|
20
|
+
let hasFailure = false;
|
|
21
|
+
for (const [id, task] of taskMap.entries()) {
|
|
22
|
+
const snap = snapMap.get(id);
|
|
23
|
+
let nodeStatus = 'pending', durationMs = 0, outputSnippet = '', retryCount = 0;
|
|
24
|
+
if (snap) {
|
|
25
|
+
nodeStatus = snap.status === 'completed' || snap.status === 'success' ? 'success' : 'failed';
|
|
26
|
+
durationMs = snap.durationMs || 0;
|
|
27
|
+
retryCount = snap.retryCount || 0;
|
|
28
|
+
if (snap.output) outputSnippet = String(snap.output).slice(0, 200);
|
|
29
|
+
if (nodeStatus === 'failed') hasFailure = true;
|
|
30
|
+
} else if (task.status === 'running') {
|
|
31
|
+
nodeStatus = 'running';
|
|
32
|
+
}
|
|
33
|
+
const deps = sanitizeDependsOn(task.dependsOn);
|
|
34
|
+
const hasBranches = Array.isArray(task.branchRules) && task.branchRules.length > 0;
|
|
35
|
+
nodes.push({
|
|
36
|
+
id,
|
|
37
|
+
title: task.title || id,
|
|
38
|
+
type: task.type || 'prompt',
|
|
39
|
+
status: nodeStatus,
|
|
40
|
+
durationMs,
|
|
41
|
+
retryCount,
|
|
42
|
+
outputSnippet,
|
|
43
|
+
isFocus: Boolean(focusTaskId && id === focusTaskId),
|
|
44
|
+
isRetryable: nodeStatus === 'failed',
|
|
45
|
+
isDecisionNode: hasBranches,
|
|
46
|
+
branchRulesCount: hasBranches ? task.branchRules.length : 0,
|
|
47
|
+
elseTask: task.elseTask || null,
|
|
48
|
+
dependsOn: deps,
|
|
49
|
+
joinPolicy: sanitizeJoinPolicy(task.joinPolicy)
|
|
50
|
+
});
|
|
51
|
+
for (const d of deps) edges.push({ from: d, to: id, type: 'dependsOn', label: task.joinPolicy === 'any_success' ? 'join:any' : 'join:all' });
|
|
52
|
+
if (hasBranches) {
|
|
53
|
+
for (const rule of task.branchRules) {
|
|
54
|
+
if (rule && rule.targetTask) {
|
|
55
|
+
edges.push({
|
|
56
|
+
from: id,
|
|
57
|
+
to: String(rule.targetTask).trim(),
|
|
58
|
+
type: 'branch',
|
|
59
|
+
label: rule.label || ('[if: ' + rule.field + ' ' + rule.operator + ' ' + (rule.value != null ? rule.value : '') + ']').trim(),
|
|
60
|
+
condition: { field: rule.field, operator: rule.operator, value: rule.value }
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (task.elseTask) {
|
|
66
|
+
edges.push({
|
|
67
|
+
from: id,
|
|
68
|
+
to: String(task.elseTask).trim(),
|
|
69
|
+
type: 'branch_else',
|
|
70
|
+
label: '[else]',
|
|
71
|
+
isElse: true
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (!hasBranches) {
|
|
75
|
+
for (const t of parseChainTargets(task.onSuccess)) edges.push({ from: id, to: t, type: 'onSuccess', label: 'onSuccess' });
|
|
76
|
+
}
|
|
77
|
+
for (const f of parseChainTargets(task.onFailure)) edges.push({ from: id, to: f, type: 'onFailure', label: 'onFailure' });
|
|
78
|
+
}
|
|
79
|
+
return { nodes, edges, hasFailure };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderChainGraphModal({ isOpen, onClose, data, loading, onRetry, t }) {
|
|
83
|
+
if (!isOpen) return null;
|
|
84
|
+
|
|
85
|
+
const graph = data?.graph || { nodes: [], edges: [], hasFailure: false };
|
|
86
|
+
const chainRunId = data?.chainRunId || '';
|
|
87
|
+
const nodes = graph.nodes || [];
|
|
88
|
+
|
|
89
|
+
return React.createElement('div', {
|
|
90
|
+
className: 'dsh-cron-modal-overlay',
|
|
91
|
+
onClick: onClose
|
|
92
|
+
},
|
|
93
|
+
React.createElement('div', {
|
|
94
|
+
className: 'dsh-cron-modal',
|
|
95
|
+
style: { maxWidth: '820px', width: '92vw', maxHeight: '88vh', display: 'flex', flexDirection: 'column' },
|
|
96
|
+
role: 'dialog',
|
|
97
|
+
'aria-modal': 'true',
|
|
98
|
+
onClick: (e) => e.stopPropagation()
|
|
99
|
+
},
|
|
100
|
+
// Header
|
|
101
|
+
React.createElement('div', {
|
|
102
|
+
className: 'dsh-cron-modal-head',
|
|
103
|
+
style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--dsw-alias-border-l2)', paddingBottom: '12px' }
|
|
104
|
+
},
|
|
105
|
+
React.createElement('div', null,
|
|
106
|
+
React.createElement('div', {
|
|
107
|
+
className: 'dsh-cron-modal-title',
|
|
108
|
+
style: { margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }
|
|
109
|
+
},
|
|
110
|
+
React.createElement('span', { style: { fontSize: '18px' } }, '🕸️'),
|
|
111
|
+
T_KEY(t, 'chainGraph.modalTitle')
|
|
112
|
+
),
|
|
113
|
+
chainRunId && React.createElement('div', {
|
|
114
|
+
style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary, #888)', marginTop: '4px', fontFamily: 'monospace' }
|
|
115
|
+
}, 'Run ID: ' + chainRunId)
|
|
116
|
+
),
|
|
117
|
+
React.createElement('button', {
|
|
118
|
+
type: 'button',
|
|
119
|
+
className: 'dsh-cron-icon-btn',
|
|
120
|
+
onClick: onClose
|
|
121
|
+
}, '✕')
|
|
122
|
+
),
|
|
123
|
+
|
|
124
|
+
// Body
|
|
125
|
+
React.createElement('div', {
|
|
126
|
+
style: { flex: 1, overflowY: 'auto', padding: '16px 0', minHeight: '260px' }
|
|
127
|
+
},
|
|
128
|
+
loading && React.createElement('div', {
|
|
129
|
+
className: 'dsh-cron-history-empty'
|
|
130
|
+
}, T_KEY(t, 'history.loading')),
|
|
131
|
+
|
|
132
|
+
!loading && nodes.length === 0 && React.createElement('div', {
|
|
133
|
+
className: 'dsh-cron-history-empty'
|
|
134
|
+
}, T_KEY(t, 'chainGraph.noNodes')),
|
|
135
|
+
|
|
136
|
+
!loading && nodes.length > 0 && React.createElement('div', {
|
|
137
|
+
style: { display: 'flex', flexDirection: 'column', gap: '16px' }
|
|
138
|
+
},
|
|
139
|
+
// Legend
|
|
140
|
+
React.createElement('div', {
|
|
141
|
+
style: { display: 'flex', gap: '12px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary, #888)', padding: '0 4px' }
|
|
142
|
+
},
|
|
143
|
+
React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '4px' } },
|
|
144
|
+
React.createElement('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: 'var(--dsh-cron-success)' } }),
|
|
145
|
+
T_KEY(t, 'chainGraph.stepSuccess')
|
|
146
|
+
),
|
|
147
|
+
React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '4px' } },
|
|
148
|
+
React.createElement('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: 'var(--dsh-cron-danger)' } }),
|
|
149
|
+
T_KEY(t, 'chainGraph.stepFailed')
|
|
150
|
+
),
|
|
151
|
+
React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '4px' } },
|
|
152
|
+
React.createElement('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: 'var(--dsw-alias-label-tertiary, #888)' } }),
|
|
153
|
+
T_KEY(t, 'chainGraph.stepPending')
|
|
154
|
+
)
|
|
155
|
+
),
|
|
156
|
+
|
|
157
|
+
// Horizontal / Flow layout of nodes
|
|
158
|
+
React.createElement('div', {
|
|
159
|
+
style: {
|
|
160
|
+
display: 'flex',
|
|
161
|
+
flexWrap: 'wrap',
|
|
162
|
+
gap: '12px',
|
|
163
|
+
alignItems: 'stretch',
|
|
164
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
165
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
166
|
+
borderRadius: '8px',
|
|
167
|
+
padding: '16px'
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
nodes.map((n, idx) => {
|
|
171
|
+
const isOk = n.status === 'success';
|
|
172
|
+
const isFailed = n.status === 'failed';
|
|
173
|
+
const borderColor = isOk
|
|
174
|
+
? 'var(--dsh-cron-success)'
|
|
175
|
+
: isFailed
|
|
176
|
+
? 'var(--dsh-cron-danger)'
|
|
177
|
+
: 'var(--dsw-alias-border-l2)';
|
|
178
|
+
|
|
179
|
+
return React.createElement('div', {
|
|
180
|
+
key: n.id || idx,
|
|
181
|
+
style: {
|
|
182
|
+
flex: '1 1 220px',
|
|
183
|
+
maxWidth: '260px',
|
|
184
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
185
|
+
border: '1px solid ' + borderColor,
|
|
186
|
+
borderRadius: '6px',
|
|
187
|
+
padding: '12px',
|
|
188
|
+
display: 'flex',
|
|
189
|
+
flexDirection: 'column',
|
|
190
|
+
gap: '8px',
|
|
191
|
+
boxShadow: isFailed ? '0 0 0 1px var(--dsh-cron-danger)' : 'none'
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
// Node header
|
|
195
|
+
React.createElement('div', {
|
|
196
|
+
style: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '6px' }
|
|
197
|
+
},
|
|
198
|
+
React.createElement('div', {
|
|
199
|
+
style: { fontWeight: '600', fontSize: '13px', color: 'var(--dsw-alias-label-primary)' }
|
|
200
|
+
}, n.title || n.id),
|
|
201
|
+
React.createElement('span', {
|
|
202
|
+
style: {
|
|
203
|
+
fontSize: '10px',
|
|
204
|
+
padding: '2px 6px',
|
|
205
|
+
borderRadius: '10px',
|
|
206
|
+
fontWeight: '600',
|
|
207
|
+
background: isOk ? 'var(--dsh-cron-success-bg)' : isFailed ? 'var(--dsh-cron-danger-bg)' : 'var(--dsw-alias-bg-layer-3)',
|
|
208
|
+
color: isOk ? 'var(--dsh-cron-success)' : isFailed ? 'var(--dsh-cron-danger)' : 'var(--dsw-alias-label-secondary)'
|
|
209
|
+
}
|
|
210
|
+
}, isOk ? '✓ ' + T_KEY(t, 'chainGraph.stepSuccess') : isFailed ? '✗ ' + T_KEY(t, 'chainGraph.stepFailed') : '⏳ ' + T_KEY(t, 'chainGraph.stepPending'))
|
|
211
|
+
),
|
|
212
|
+
|
|
213
|
+
// Meta: duration, retryCount, type
|
|
214
|
+
React.createElement('div', {
|
|
215
|
+
style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', display: 'flex', gap: '8px', flexWrap: 'wrap' }
|
|
216
|
+
},
|
|
217
|
+
n.durationMs > 0 && React.createElement('span', null, n.durationMs + 'ms'),
|
|
218
|
+
n.retryCount > 0 && React.createElement('span', { style: { color: 'var(--dsh-cron-warning)' } }, '↻ ' + n.retryCount),
|
|
219
|
+
n.joinPolicy && n.dependsOn?.length > 0 && React.createElement('span', {
|
|
220
|
+
style: { background: 'var(--dsw-alias-bg-layer-3)', padding: '1px 4px', borderRadius: '4px' }
|
|
221
|
+
}, n.joinPolicy === 'any_success' ? 'join:any' : 'join:all')
|
|
222
|
+
),
|
|
223
|
+
|
|
224
|
+
// Decision Node info (#352)
|
|
225
|
+
n.isDecisionNode && React.createElement('div', {
|
|
226
|
+
style: { fontSize: '11px', color: 'var(--dsh-cron-warning)', fontWeight: '600', display: 'flex', alignItems: 'center', gap: '4px' }
|
|
227
|
+
}, '🔀 ' + T_KEY(t, 'chainGraph.decisionNode') + ' (' + n.branchRulesCount + ')'),
|
|
228
|
+
|
|
229
|
+
// Dependencies info
|
|
230
|
+
n.dependsOn?.length > 0 && React.createElement('div', {
|
|
231
|
+
style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' }
|
|
232
|
+
}, '← ' + n.dependsOn.join(', ')),
|
|
233
|
+
|
|
234
|
+
// Output snippet
|
|
235
|
+
n.outputSnippet && React.createElement('div', {
|
|
236
|
+
style: {
|
|
237
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
238
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
239
|
+
borderRadius: '4px',
|
|
240
|
+
padding: '6px',
|
|
241
|
+
fontSize: '11px',
|
|
242
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
243
|
+
fontFamily: 'monospace',
|
|
244
|
+
whiteSpace: 'pre-wrap',
|
|
245
|
+
wordBreak: 'break-all',
|
|
246
|
+
maxHeight: '60px',
|
|
247
|
+
overflowY: 'auto'
|
|
248
|
+
}
|
|
249
|
+
}, n.outputSnippet),
|
|
250
|
+
|
|
251
|
+
// One-click Retry from step button (#319, #328, #334)
|
|
252
|
+
isFailed && chainRunId && React.createElement('button', {
|
|
253
|
+
type: 'button',
|
|
254
|
+
className: 'dsh-cron-btn-secondary',
|
|
255
|
+
style: {
|
|
256
|
+
marginTop: 'auto',
|
|
257
|
+
display: 'flex',
|
|
258
|
+
alignItems: 'center',
|
|
259
|
+
justifyContent: 'center',
|
|
260
|
+
gap: '6px',
|
|
261
|
+
fontSize: '12px',
|
|
262
|
+
padding: '6px 10px',
|
|
263
|
+
color: 'var(--dsh-cron-danger)',
|
|
264
|
+
borderColor: 'var(--dsh-cron-danger)'
|
|
265
|
+
},
|
|
266
|
+
onClick: () => onRetry && onRetry(n.id)
|
|
267
|
+
}, '🔄 ' + T_KEY(t, 'chainGraph.retryStep'))
|
|
268
|
+
);
|
|
269
|
+
})
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
),
|
|
273
|
+
|
|
274
|
+
// Footer
|
|
275
|
+
React.createElement('div', {
|
|
276
|
+
className: 'dsh-cron-modal-foot',
|
|
277
|
+
style: { borderTop: '1px solid var(--dsw-alias-border-l2)', paddingTop: '12px', marginTop: 'auto' }
|
|
278
|
+
},
|
|
279
|
+
React.createElement('button', {
|
|
280
|
+
type: 'button',
|
|
281
|
+
className: 'dsh-cron-btn-primary',
|
|
282
|
+
onClick: onClose
|
|
283
|
+
}, T_KEY(t, 'chainGraph.close'))
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
);
|
|
287
|
+
}
|