@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,200 @@
|
|
|
1
|
+
function mountCronScreen(ctx, toggle) {
|
|
2
|
+
if (typeof document === 'undefined') return () => {};
|
|
3
|
+
let root;
|
|
4
|
+
let container;
|
|
5
|
+
|
|
6
|
+
const teardownPanel = () => {
|
|
7
|
+
if (root !== undefined) {
|
|
8
|
+
try { root.unmount(); } catch (e) { /* ignore */ }
|
|
9
|
+
root = undefined;
|
|
10
|
+
}
|
|
11
|
+
if (container !== undefined) {
|
|
12
|
+
try { container.remove(); } catch (e) { /* ignore */ }
|
|
13
|
+
container = undefined;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ensure = () => {
|
|
18
|
+
if (!toggle.isOpen()) {
|
|
19
|
+
teardownPanel();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (container !== undefined && container.isConnected) return;
|
|
23
|
+
const column = document.querySelector(COLUMN_SELECTOR);
|
|
24
|
+
if (column === null) return;
|
|
25
|
+
container = document.createElement('div');
|
|
26
|
+
container.setAttribute(VIEW_ATTR, '');
|
|
27
|
+
container.setAttribute('data-dsh-plugin', 'dsh-cron');
|
|
28
|
+
column.appendChild(container);
|
|
29
|
+
if (ReactDOM && typeof ReactDOM.createRoot === 'function') {
|
|
30
|
+
root = ReactDOM.createRoot(container);
|
|
31
|
+
root.render(React.createElement(ErrorBoundary, null, React.createElement(CronScreen, { ctx, toggle, onClose: () => toggle.set(false), t: translate })));
|
|
32
|
+
} else if (ReactDOM && typeof ReactDOM.render === 'function') {
|
|
33
|
+
ReactDOM.render(React.createElement(CronScreen, { ctx, toggle, onClose: () => toggle.set(false), t: translate }), container);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const applyActive = () => {
|
|
38
|
+
if (toggle.isOpen()) {
|
|
39
|
+
// Repair a dropped stylesheet before the panel paints, so opening the
|
|
40
|
+
// screen never shows an unstyled page (see startStyleSelfHeal).
|
|
41
|
+
ensureStyles();
|
|
42
|
+
document.documentElement.setAttribute(ACTIVE_ATTR, '');
|
|
43
|
+
document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL }));
|
|
44
|
+
ensure();
|
|
45
|
+
} else {
|
|
46
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR);
|
|
47
|
+
teardownPanel();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const onOtherActivate = (event) => {
|
|
52
|
+
if (event.detail !== PANEL && toggle.isOpen()) toggle.set(false);
|
|
53
|
+
};
|
|
54
|
+
const onSidebarClick = (event) => {
|
|
55
|
+
if (!toggle.isOpen()) return;
|
|
56
|
+
const target = event.target;
|
|
57
|
+
if (target !== null && target.closest && target.closest(SESSION_ROW_SELECTOR) !== null) toggle.set(false);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const waitObserver = new MutationObserver(ensure);
|
|
61
|
+
waitObserver.observe(document.body, { childList: true, subtree: true });
|
|
62
|
+
document.addEventListener(ACTIVATE_EVENT, onOtherActivate);
|
|
63
|
+
document.addEventListener('click', onSidebarClick, true);
|
|
64
|
+
const unsubscribe = toggle.subscribe(applyActive);
|
|
65
|
+
applyActive();
|
|
66
|
+
ensure();
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
waitObserver.disconnect();
|
|
70
|
+
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate);
|
|
71
|
+
document.removeEventListener('click', onSidebarClick, true);
|
|
72
|
+
unsubscribe();
|
|
73
|
+
document.documentElement.removeAttribute(ACTIVE_ATTR);
|
|
74
|
+
teardownPanel();
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Register a card into a DSH mount point. Mount points are populated via
|
|
80
|
+
* ctx.slots.inject(mount, cb) — a direct slots.register does not throw but
|
|
81
|
+
* also never appears in the target surface (settings plugins tab, header),
|
|
82
|
+
* so the inject contract is mandatory (#85).
|
|
83
|
+
*/
|
|
84
|
+
function registerIntoMount(ctx, mountName, entry, component) {
|
|
85
|
+
if (!ctx || !ctx.slots || typeof ctx.slots.inject !== 'function') return false;
|
|
86
|
+
try {
|
|
87
|
+
return !!ctx.slots.inject(mountName, () => {
|
|
88
|
+
ctx.slots.register(entry, component);
|
|
89
|
+
});
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.warn('[dsh-cron] slot injection skipped:', mountName, '-', err && err.message);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function apply(ctx) {
|
|
97
|
+
const stopStyleSelfHeal = startStyleSelfHeal();
|
|
98
|
+
|
|
99
|
+
// Register the English canonical strings; rebinding the translator lets
|
|
100
|
+
// the translation plugin drive the active language at runtime (#87).
|
|
101
|
+
// Own the dictionary as an effect: fiber dispose/HMR removes exactly this
|
|
102
|
+
// registration, so a re-applied plugin registers again instead of throwing
|
|
103
|
+
// "locale namespace ... already has locale ..." into a swallowed catch and
|
|
104
|
+
// leaving the previously registered (stale) dictionary in place.
|
|
105
|
+
try {
|
|
106
|
+
if (ctx.locale && typeof ctx.locale.register === 'function' && typeof ctx.effect === 'function') {
|
|
107
|
+
ctx.effect(() => ctx.locale.register(NS, STRINGS), 'dsh-cron: locale dictionaries');
|
|
108
|
+
} else if (ctx.locale && typeof ctx.locale.register === 'function') {
|
|
109
|
+
ctx.locale.register(NS, STRINGS);
|
|
110
|
+
}
|
|
111
|
+
} catch (e) {
|
|
112
|
+
// A plain re-registration in the same session throws; the effect form above avoids it.
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
if (ctx.locale && typeof ctx.locale.bind === 'function') {
|
|
116
|
+
translate = ctx.locale.bind(NS);
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
/* locale bind unavailable, fallback to default translate */
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const toggle = createToggle();
|
|
123
|
+
|
|
124
|
+
// The settings card lives ONLY in the plugins settings tab (#102): no
|
|
125
|
+
// top-level section fallback — the flat sidebar list is a shared core
|
|
126
|
+
// resource, and the card slot is proven on current builds (#85).
|
|
127
|
+
function SettingsCardWithBoundary(props) {
|
|
128
|
+
return React.createElement(ErrorBoundary, null, CronSettingsCard(props));
|
|
129
|
+
}
|
|
130
|
+
// List seat (plugins.item): the seat the Plugins page renders as the plugin's own
|
|
131
|
+
// page with its configuration. The label is a static string on purpose — it is
|
|
132
|
+
// resolved while the page renders, and a locale lookup there would take the whole
|
|
133
|
+
// client batch down with it.
|
|
134
|
+
const itemOk = registerIntoMount(ctx, 'plugins.item', {
|
|
135
|
+
name: 'plugins.item',
|
|
136
|
+
id: ROW_ID,
|
|
137
|
+
order: 60,
|
|
138
|
+
label: () => 'Cron',
|
|
139
|
+
locale: NS,
|
|
140
|
+
inject: () => ({ ctx, toggle }),
|
|
141
|
+
}, SettingsCardWithBoundary);
|
|
142
|
+
if (!itemOk) {
|
|
143
|
+
console.warn('[dsh-cron] plugins.item slot unavailable — plugin page card not registered');
|
|
144
|
+
}
|
|
145
|
+
// Row seat and the legacy settings.plugin.item seat stay as fallbacks
|
|
146
|
+
// (#102: still no top-level section fallback).
|
|
147
|
+
const rowOk = registerIntoMount(ctx, 'plugins.row.config', {
|
|
148
|
+
name: 'plugins.row.config',
|
|
149
|
+
key: ROW_CONFIG_KEY,
|
|
150
|
+
locale: NS,
|
|
151
|
+
inject: () => ({ ctx, toggle }),
|
|
152
|
+
}, SettingsCardWithBoundary);
|
|
153
|
+
const cardOk = registerIntoMount(ctx, 'settings.plugin.item', {
|
|
154
|
+
name: 'settings.plugin.item',
|
|
155
|
+
key: NS,
|
|
156
|
+
locale: NS,
|
|
157
|
+
inject: () => ({ ctx, toggle }),
|
|
158
|
+
}, SettingsCardWithBoundary);
|
|
159
|
+
if (!cardOk) {
|
|
160
|
+
console.warn('[dsh-cron] settings.plugin.item slot unavailable — settings card not registered');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const chipOk = registerIntoMount(ctx, 'conversation.session.header.utilities', {
|
|
164
|
+
name: 'conversation.session.header.utilities',
|
|
165
|
+
id: PLUGIN_ID + '.chip',
|
|
166
|
+
order: 27,
|
|
167
|
+
locale: NS,
|
|
168
|
+
inject: () => ({ ctx, toggle }),
|
|
169
|
+
}, CronChip);
|
|
170
|
+
const cardSlot = cardOk ? 'settings.plugin.item' : undefined;
|
|
171
|
+
const chipSlot = chipOk ? 'conversation.session.header.utilities' : undefined;
|
|
172
|
+
|
|
173
|
+
function CronChip(props) {
|
|
174
|
+
const chipT = props.t || translate;
|
|
175
|
+
return React.createElement('button', {
|
|
176
|
+
type: 'button',
|
|
177
|
+
style: { background: 'none', border: 'none', color: 'var(--dsw-alias-label-secondary, #999)', cursor: 'pointer', padding: '4px', display: 'inline-flex', alignItems: 'center' },
|
|
178
|
+
title: T_KEY(chipT, 'chip.title'),
|
|
179
|
+
'aria-label': T_KEY(chipT, 'chip.title'),
|
|
180
|
+
onClick: () => toggle.toggle(),
|
|
181
|
+
dangerouslySetInnerHTML: { __html: ICON_TIMER }
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
ctx.effect(() => {
|
|
186
|
+
const off = [
|
|
187
|
+
mountSidebarEntry(toggle, translate('sidebar.label')),
|
|
188
|
+
mountSidebarJobs(toggle, translate('sidebar.jobsTitle'), (taskId) => { pendingTaskHighlight = taskId; }),
|
|
189
|
+
mountCronScreen(ctx, toggle)
|
|
190
|
+
];
|
|
191
|
+
return () => { for (const dispose of off) dispose(); stopStyleSelfHeal(); };
|
|
192
|
+
}, 'dsh-cron: client overlay');
|
|
193
|
+
|
|
194
|
+
exports.slots = { card: cardSlot, chip: chipSlot };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { apply, inject: ['slots', 'locale'] };
|
|
198
|
+
return module.exports;
|
|
199
|
+
}
|
|
200
|
+
})
|
package/lib/client.js
CHANGED
|
@@ -2736,14 +2736,46 @@ if (window.__dshLiveLogsEvtSource) {
|
|
|
2736
2736
|
try { window.__dshLiveLogsEvtSource.close(); } catch (e) { void e; }
|
|
2737
2737
|
window.__dshLiveLogsEvtSource = null;
|
|
2738
2738
|
}
|
|
2739
|
+
if (window.__dshLiveLogsPollTimer) {
|
|
2740
|
+
clearInterval(window.__dshLiveLogsPollTimer);
|
|
2741
|
+
window.__dshLiveLogsPollTimer = null;
|
|
2742
|
+
}
|
|
2739
2743
|
setLiveLogsTaskId(task.id);
|
|
2740
2744
|
setLiveLogsTaskTitle(task.title || task.id);
|
|
2741
2745
|
setLiveLogsLines([]);
|
|
2742
2746
|
setLiveLogsStatus(task.running ? 'connecting' : 'idle');
|
|
2743
2747
|
setLiveLogsCompleted(false);
|
|
2744
2748
|
setLiveLogsModalOpen(true);
|
|
2749
|
+
const startPollingFallback = () => {
|
|
2750
|
+
if (window.__dshLiveLogsPollTimer) return;
|
|
2751
|
+
const pollFn = () => {
|
|
2752
|
+
fetchJsonWithTimeout('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/history?limit=1')
|
|
2753
|
+
.then((data) => {
|
|
2754
|
+
if (data && data.history && data.history.length > 0) {
|
|
2755
|
+
const lastRun = data.history[0];
|
|
2756
|
+
if (lastRun.output || lastRun.error) {
|
|
2757
|
+
const text = lastRun.output || lastRun.error;
|
|
2758
|
+
setLiveLogsLines([{ chunk: text, stream: lastRun.error ? 'stderr' : 'stdout', ts: lastRun.at }]);
|
|
2759
|
+
}
|
|
2760
|
+
if (lastRun.status) {
|
|
2761
|
+
setLiveLogsStatus(lastRun.status);
|
|
2762
|
+
setLiveLogsCompleted(true);
|
|
2763
|
+
if (window.__dshLiveLogsPollTimer) {
|
|
2764
|
+
clearInterval(window.__dshLiveLogsPollTimer);
|
|
2765
|
+
window.__dshLiveLogsPollTimer = null;
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
})
|
|
2770
|
+
.catch(() => {});
|
|
2771
|
+
};
|
|
2772
|
+
pollFn();
|
|
2773
|
+
window.__dshLiveLogsPollTimer = setInterval(pollFn, 2000);
|
|
2774
|
+
};
|
|
2775
|
+
if (typeof EventSource !== 'undefined') {
|
|
2745
2776
|
try {
|
|
2746
|
-
const
|
|
2777
|
+
const streamUrl = '/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/logs/stream';
|
|
2778
|
+
const es = new EventSource(streamUrl);
|
|
2747
2779
|
window.__dshLiveLogsEvtSource = es;
|
|
2748
2780
|
es.addEventListener('init', (e) => {
|
|
2749
2781
|
try {
|
|
@@ -2767,12 +2799,21 @@ try { es.close(); } catch (e) { void e; }
|
|
|
2767
2799
|
window.__dshLiveLogsEvtSource = null;
|
|
2768
2800
|
});
|
|
2769
2801
|
es.onerror = () => {
|
|
2770
|
-
setLiveLogsStatus((curr) => curr === 'running' ? 'completed' : 'closed');
|
|
2771
2802
|
try { es.close(); } catch (e) { void e; }
|
|
2772
2803
|
window.__dshLiveLogsEvtSource = null;
|
|
2804
|
+
setLiveLogsStatus((curr) => {
|
|
2805
|
+
if (curr === 'running' || curr === 'connecting') {
|
|
2806
|
+
startPollingFallback();
|
|
2807
|
+
return curr;
|
|
2808
|
+
}
|
|
2809
|
+
return 'completed';
|
|
2810
|
+
});
|
|
2773
2811
|
};
|
|
2774
2812
|
} catch (err) {
|
|
2775
|
-
|
|
2813
|
+
startPollingFallback();
|
|
2814
|
+
}
|
|
2815
|
+
} else {
|
|
2816
|
+
startPollingFallback();
|
|
2776
2817
|
}
|
|
2777
2818
|
}
|
|
2778
2819
|
function handleCloseLiveLogs() {
|
|
@@ -2780,6 +2821,10 @@ if (window.__dshLiveLogsEvtSource) {
|
|
|
2780
2821
|
try { window.__dshLiveLogsEvtSource.close(); } catch (e) { void e; }
|
|
2781
2822
|
window.__dshLiveLogsEvtSource = null;
|
|
2782
2823
|
}
|
|
2824
|
+
if (window.__dshLiveLogsPollTimer) {
|
|
2825
|
+
clearInterval(window.__dshLiveLogsPollTimer);
|
|
2826
|
+
window.__dshLiveLogsPollTimer = null;
|
|
2827
|
+
}
|
|
2783
2828
|
setLiveLogsModalOpen(false);
|
|
2784
2829
|
}
|
|
2785
2830
|
const openAnalyticsDigest = async (days = 7) => {
|
package/lib/commands.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-commands registration for /cron slash commands (#388).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { GlobalBudgetManager } from './global-budgets.js';
|
|
6
|
+
|
|
7
|
+
export function formatTaskList(tasks = []) {
|
|
8
|
+
if (!tasks.length) {
|
|
9
|
+
return 'No scheduled tasks configured. Create one in the Cron panel or via cron tool.';
|
|
10
|
+
}
|
|
11
|
+
const lines = [
|
|
12
|
+
`Cron Tasks (${tasks.length} total):`,
|
|
13
|
+
'',
|
|
14
|
+
];
|
|
15
|
+
for (const t of tasks) {
|
|
16
|
+
const statusIcon = t.paused || t.status === 'paused' ? '[PAUSED]' : (t.status === 'completed' ? '[DONE]' : '[ACTIVE]');
|
|
17
|
+
const nextStr = t.nextRunAt ? new Date(t.nextRunAt).toISOString().replace('T', ' ').slice(0, 16) : 'none';
|
|
18
|
+
const scheduleStr = t.scheduleText || t.schedule || 'manual';
|
|
19
|
+
lines.push(` ${statusIcon} ${t.id} · "${t.title}"`);
|
|
20
|
+
lines.push(` Schedule: ${scheduleStr} · Next: ${nextStr} · Type: ${t.type || 'llm'}`);
|
|
21
|
+
}
|
|
22
|
+
return lines.join('\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatSchedulerStatus(store, scheduler) {
|
|
26
|
+
const allTasks = store?.list ? store.list() : [];
|
|
27
|
+
const activeCount = allTasks.filter((t) => !t.paused && t.status !== 'paused' && t.status !== 'completed').length;
|
|
28
|
+
const pausedCount = allTasks.filter((t) => t.paused || t.status === 'paused').length;
|
|
29
|
+
const runningCount = typeof scheduler?.runningCount === 'function' ? scheduler.runningCount() : (scheduler?.running?.size || 0);
|
|
30
|
+
const queueDepth = Array.isArray(scheduler?.queue) ? scheduler.queue.length : 0;
|
|
31
|
+
const maxConcurrent = scheduler?.maxConcurrent ?? 2;
|
|
32
|
+
|
|
33
|
+
const budgetMgr = new GlobalBudgetManager();
|
|
34
|
+
const usage24h = budgetMgr.calculateUsage(store, { windowMs: 86400000 });
|
|
35
|
+
const settings = store?.getSettings?.() || {};
|
|
36
|
+
const dailyLimit = Number(settings.globalDailyCostLimitUsd) || 0;
|
|
37
|
+
const budgetStr = dailyLimit > 0
|
|
38
|
+
? `$${usage24h.totalCostUsd.toFixed(4)} / $${dailyLimit.toFixed(2)}`
|
|
39
|
+
: `$${usage24h.totalCostUsd.toFixed(4)} (unlimited)`;
|
|
40
|
+
|
|
41
|
+
const lines = [
|
|
42
|
+
'=== Cron Scheduler Status ===',
|
|
43
|
+
`Active Tasks: ${activeCount} (Paused: ${pausedCount}, Total: ${allTasks.length})`,
|
|
44
|
+
`Currently Running: ${runningCount} / ${maxConcurrent} max concurrent`,
|
|
45
|
+
`Queue Depth: ${queueDepth}`,
|
|
46
|
+
`24h Expenditure: ${budgetStr} (${usage24h.runCount} runs, ${usage24h.totalTokens} tokens)`,
|
|
47
|
+
`Timezone: ${scheduler?.defaultTimezone || 'Server Local'}`,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
if (runningCount > 0 && scheduler?.running) {
|
|
51
|
+
lines.push('', 'Active Runs:');
|
|
52
|
+
for (const [id, runInfo] of scheduler.running.entries()) {
|
|
53
|
+
const task = store?.get?.(id);
|
|
54
|
+
const elapsedSec = runInfo?.startedAt ? Math.round((Date.now() - runInfo.startedAt) / 1000) : 0;
|
|
55
|
+
lines.push(` - ${task ? task.title : id} (${id}) running for ${elapsedSec}s`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return lines.join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function executeCronCommand(store, scheduler, rawInput = '') {
|
|
63
|
+
const args = String(rawInput || '').trim().split(/\s+/).filter(Boolean);
|
|
64
|
+
const subcmd = (args[0] || 'list').toLowerCase();
|
|
65
|
+
const targetId = args[1];
|
|
66
|
+
|
|
67
|
+
switch (subcmd) {
|
|
68
|
+
case 'list':
|
|
69
|
+
case 'ls': {
|
|
70
|
+
const tasks = store.list ? store.list() : [];
|
|
71
|
+
return formatTaskList(tasks);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
case 'status':
|
|
75
|
+
case 'stat': {
|
|
76
|
+
return formatSchedulerStatus(store, scheduler);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
case 'run':
|
|
80
|
+
case 'trigger': {
|
|
81
|
+
if (!targetId) {
|
|
82
|
+
return 'Error: Task ID is required. Example: /cron run <task-id>';
|
|
83
|
+
}
|
|
84
|
+
const task = store.get ? store.get(targetId) : null;
|
|
85
|
+
if (!task) {
|
|
86
|
+
return `Error: Task "${targetId}" not found. Run "/cron list" to view available task IDs.`;
|
|
87
|
+
}
|
|
88
|
+
if (typeof scheduler?.triggerManualRun === 'function') {
|
|
89
|
+
scheduler.triggerManualRun(task.id);
|
|
90
|
+
} else if (typeof scheduler?.runNow === 'function') {
|
|
91
|
+
await scheduler.runNow(task.id);
|
|
92
|
+
} else {
|
|
93
|
+
return 'Error: Scheduler does not support manual run triggering.';
|
|
94
|
+
}
|
|
95
|
+
return `Triggered manual execution for task "${task.title}" (${task.id}).`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
case 'pause': {
|
|
99
|
+
if (!targetId) {
|
|
100
|
+
return 'Error: Task ID is required. Example: /cron pause <task-id>';
|
|
101
|
+
}
|
|
102
|
+
const task = store.get ? store.get(targetId) : null;
|
|
103
|
+
if (!task) {
|
|
104
|
+
return `Error: Task "${targetId}" not found. Run "/cron list" to view available task IDs.`;
|
|
105
|
+
}
|
|
106
|
+
if (typeof scheduler?.pauseTask === 'function') {
|
|
107
|
+
scheduler.pauseTask(task.id);
|
|
108
|
+
} else if (store?.update) {
|
|
109
|
+
store.update(task.id, { paused: true, status: 'paused' });
|
|
110
|
+
}
|
|
111
|
+
return `Task "${task.title}" (${task.id}) is now paused.`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
case 'resume': {
|
|
115
|
+
if (!targetId) {
|
|
116
|
+
return 'Error: Task ID is required. Example: /cron resume <task-id>';
|
|
117
|
+
}
|
|
118
|
+
const task = store.get ? store.get(targetId) : null;
|
|
119
|
+
if (!task) {
|
|
120
|
+
return `Error: Task "${targetId}" not found. Run "/cron list" to view available task IDs.`;
|
|
121
|
+
}
|
|
122
|
+
if (typeof scheduler?.resumeTask === 'function') {
|
|
123
|
+
scheduler.resumeTask(task.id);
|
|
124
|
+
} else if (store?.update) {
|
|
125
|
+
store.update(task.id, { paused: false, status: 'active' });
|
|
126
|
+
}
|
|
127
|
+
return `Task "${task.title}" (${task.id}) has been resumed.`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
case 'help':
|
|
131
|
+
default: {
|
|
132
|
+
return [
|
|
133
|
+
'Cron Automation Commands:',
|
|
134
|
+
' /cron list - List all scheduled tasks with next execution time',
|
|
135
|
+
' /cron status - View scheduler queue, active runs, and 24h spend',
|
|
136
|
+
' /cron run <id> - Manually trigger an immediate run for a task',
|
|
137
|
+
' /cron pause <id> - Pause scheduled executions for a task',
|
|
138
|
+
' /cron resume <id> - Resume scheduled executions for a task',
|
|
139
|
+
].join('\n');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function registerCommands(ctx, { store, scheduler }) {
|
|
145
|
+
if (typeof ctx?.inject !== 'function') return;
|
|
146
|
+
ctx.inject(['commands'], (cmdCtx) => {
|
|
147
|
+
let commandsService;
|
|
148
|
+
try {
|
|
149
|
+
commandsService = cmdCtx?.commands || cmdCtx?.get?.('commands');
|
|
150
|
+
} catch {
|
|
151
|
+
commandsService = undefined;
|
|
152
|
+
}
|
|
153
|
+
if (!commandsService || typeof commandsService.register !== 'function') return;
|
|
154
|
+
|
|
155
|
+
const commandHandler = async (invocation) => {
|
|
156
|
+
try {
|
|
157
|
+
const rawArgs = typeof invocation === 'string'
|
|
158
|
+
? invocation
|
|
159
|
+
: (invocation?.rawArgs || invocation?.input || '');
|
|
160
|
+
const text = await executeCronCommand(store, scheduler, rawArgs);
|
|
161
|
+
return {
|
|
162
|
+
kind: 'text',
|
|
163
|
+
text,
|
|
164
|
+
toString() { return text; },
|
|
165
|
+
};
|
|
166
|
+
} catch (err) {
|
|
167
|
+
const errText = `Cron command error: ${err?.message || err}`;
|
|
168
|
+
return {
|
|
169
|
+
kind: 'error',
|
|
170
|
+
text: errText,
|
|
171
|
+
toString() { return errText; },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const commandExecute = async (rawArgs, invocation = null) => {
|
|
177
|
+
const res = await commandHandler(invocation || rawArgs);
|
|
178
|
+
return res.text;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const unregister = commandsService.register({
|
|
183
|
+
definitionId: '@goodandready/dsh-cron',
|
|
184
|
+
name: 'cron',
|
|
185
|
+
description: 'Manage scheduled tasks and background automation (/cron [list|status|run <id>|pause <id>|resume <id>])',
|
|
186
|
+
input: { hint: '[list|status|run <id>|pause <id>|resume <id>]' },
|
|
187
|
+
handler: commandHandler,
|
|
188
|
+
execute: commandExecute,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (typeof ctx.effect === 'function') {
|
|
192
|
+
ctx.effect(() => () => {
|
|
193
|
+
try { unregister?.(); } catch { /* explicit cleanup fallback */ }
|
|
194
|
+
}, 'dsh-cron: slash command');
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
ctx?.logger?.warn?.('[dsh-cron] Could not register /cron slash command: ' + (err?.message || err));
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
package/lib/durable-journal.js
CHANGED
|
@@ -1,188 +1 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
export * from './store/durable-journal.js';
|