@goodandready/dsh-cron 0.3.25 → 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.
Files changed (82) hide show
  1. package/lib/api-actions.js +1 -317
  2. package/lib/api-collection.js +1 -250
  3. package/lib/api-helpers.js +1 -100
  4. package/lib/api-import-export.js +1 -155
  5. package/lib/api-item.js +1 -191
  6. package/lib/api-task-webhook.js +1 -247
  7. package/lib/api-webhook.js +1 -183
  8. package/lib/api.js +1 -178
  9. package/lib/artifacts.js +1 -200
  10. package/lib/chain-checkpoints.js +1 -223
  11. package/lib/client-src/00-open.js +62 -0
  12. package/lib/client-src/10-locales.js +484 -0
  13. package/lib/client-src/20-styles.js +64 -0
  14. package/lib/client-src/30-utils.js +248 -0
  15. package/lib/client-src/40-delivery-settings.js +96 -0
  16. package/lib/client-src/45-dag-graph-view.js +287 -0
  17. package/lib/client-src/46-live-logs-modal.js +108 -0
  18. package/lib/client-src/47-analytics-digest-modal.js +166 -0
  19. package/lib/client-src/48-timeline-heatmap-view.js +176 -0
  20. package/lib/client-src/50-cron-screen-state.js +447 -0
  21. package/lib/client-src/52-cron-screen-actions.js +330 -0
  22. package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
  23. package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
  24. package/lib/client-src/55-cron-screen-view.js +378 -0
  25. package/lib/client-src/60-modal-task-form-head.js +580 -0
  26. package/lib/client-src/62-modal-task-form-tail.js +466 -0
  27. package/lib/client-src/65-modal-dialogs.js +350 -0
  28. package/lib/client-src/70-settings-card.js +420 -0
  29. package/lib/client-src/80-sidebar-jobs.js +200 -0
  30. package/lib/client-src/90-close.js +200 -0
  31. package/lib/commands.js +200 -0
  32. package/lib/durable-journal.js +1 -188
  33. package/lib/external-api.js +1 -74
  34. package/lib/index.js +17 -0
  35. package/lib/metrics.js +1 -125
  36. package/lib/routes/api-actions.js +317 -0
  37. package/lib/routes/api-collection.js +250 -0
  38. package/lib/routes/api-helpers.js +100 -0
  39. package/lib/routes/api-import-export.js +155 -0
  40. package/lib/routes/api-item.js +191 -0
  41. package/lib/routes/api-task-webhook.js +247 -0
  42. package/lib/routes/api-webhook.js +183 -0
  43. package/lib/routes/api.js +178 -0
  44. package/lib/routes/external-api.js +74 -0
  45. package/lib/routes/index.js +11 -0
  46. package/lib/routes/metrics.js +125 -0
  47. package/lib/routes/routes.js +549 -0
  48. package/lib/routes.js +1 -549
  49. package/lib/runner-external.js +1 -352
  50. package/lib/runner-worktree.js +1 -90
  51. package/lib/runner.js +1 -881
  52. package/lib/runtimes/index.js +4 -0
  53. package/lib/runtimes/runner-external.js +352 -0
  54. package/lib/runtimes/runner-worktree.js +90 -0
  55. package/lib/runtimes/runner.js +881 -0
  56. package/lib/runtimes/runtimes.js +198 -0
  57. package/lib/runtimes.js +1 -198
  58. package/lib/scheduler/index.js +6 -0
  59. package/lib/scheduler/scheduler-assists.js +142 -0
  60. package/lib/scheduler/scheduler-completion.js +417 -0
  61. package/lib/scheduler/scheduler-execution.js +360 -0
  62. package/lib/scheduler/scheduler-lifecycle.js +157 -0
  63. package/lib/scheduler/scheduler-preflight.js +195 -0
  64. package/lib/scheduler/scheduler.js +751 -0
  65. package/lib/scheduler-assists.js +1 -142
  66. package/lib/scheduler-completion.js +1 -417
  67. package/lib/scheduler-execution.js +1 -360
  68. package/lib/scheduler-lifecycle.js +1 -157
  69. package/lib/scheduler-preflight.js +1 -195
  70. package/lib/scheduler.js +1 -751
  71. package/lib/store/artifacts.js +200 -0
  72. package/lib/store/chain-checkpoints.js +223 -0
  73. package/lib/store/durable-journal.js +188 -0
  74. package/lib/store/index.js +6 -0
  75. package/lib/store/store.js +1179 -0
  76. package/lib/store/task-schema.js +180 -0
  77. package/lib/store/task-transfer.js +310 -0
  78. package/lib/store.js +2 -1179
  79. package/lib/task-schema.js +1 -180
  80. package/lib/task-transfer.js +1 -310
  81. package/lib/tui-settings.js +157 -0
  82. package/package.json +2 -1
@@ -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
+ }