@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.
Files changed (84) 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 -190
  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/client.js +48 -3
  32. package/lib/commands.js +200 -0
  33. package/lib/durable-journal.js +1 -188
  34. package/lib/external-api.js +1 -74
  35. package/lib/index.js +17 -0
  36. package/lib/log-streamer.js +10 -2
  37. package/lib/metrics.js +1 -125
  38. package/lib/routes/api-actions.js +317 -0
  39. package/lib/routes/api-collection.js +250 -0
  40. package/lib/routes/api-helpers.js +100 -0
  41. package/lib/routes/api-import-export.js +155 -0
  42. package/lib/routes/api-item.js +191 -0
  43. package/lib/routes/api-task-webhook.js +247 -0
  44. package/lib/routes/api-webhook.js +183 -0
  45. package/lib/routes/api.js +178 -0
  46. package/lib/routes/external-api.js +74 -0
  47. package/lib/routes/index.js +11 -0
  48. package/lib/routes/metrics.js +125 -0
  49. package/lib/routes/routes.js +549 -0
  50. package/lib/routes.js +1 -549
  51. package/lib/runner-external.js +1 -352
  52. package/lib/runner-worktree.js +1 -90
  53. package/lib/runner.js +1 -881
  54. package/lib/runtimes/index.js +4 -0
  55. package/lib/runtimes/runner-external.js +352 -0
  56. package/lib/runtimes/runner-worktree.js +90 -0
  57. package/lib/runtimes/runner.js +881 -0
  58. package/lib/runtimes/runtimes.js +198 -0
  59. package/lib/runtimes.js +1 -198
  60. package/lib/scheduler/index.js +6 -0
  61. package/lib/scheduler/scheduler-assists.js +142 -0
  62. package/lib/scheduler/scheduler-completion.js +417 -0
  63. package/lib/scheduler/scheduler-execution.js +360 -0
  64. package/lib/scheduler/scheduler-lifecycle.js +157 -0
  65. package/lib/scheduler/scheduler-preflight.js +195 -0
  66. package/lib/scheduler/scheduler.js +751 -0
  67. package/lib/scheduler-assists.js +1 -142
  68. package/lib/scheduler-completion.js +1 -417
  69. package/lib/scheduler-execution.js +1 -360
  70. package/lib/scheduler-lifecycle.js +1 -157
  71. package/lib/scheduler-preflight.js +1 -195
  72. package/lib/scheduler.js +1 -751
  73. package/lib/store/artifacts.js +200 -0
  74. package/lib/store/chain-checkpoints.js +223 -0
  75. package/lib/store/durable-journal.js +188 -0
  76. package/lib/store/index.js +6 -0
  77. package/lib/store/store.js +1179 -0
  78. package/lib/store/task-schema.js +180 -0
  79. package/lib/store/task-transfer.js +310 -0
  80. package/lib/store.js +2 -1179
  81. package/lib/task-schema.js +1 -180
  82. package/lib/task-transfer.js +1 -310
  83. package/lib/tui-settings.js +157 -0
  84. 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 es = new EventSource('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/stream');
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
- setLiveLogsStatus('error');
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) => {
@@ -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
+ }
@@ -1,188 +1 @@
1
- import { createHash, randomUUID } from 'node:crypto';
2
-
3
- /**
4
- * States for durable task execution lifecycle (#317)
5
- */
6
- export const JOURNAL_STATES = {
7
- QUEUED: 'queued',
8
- RUNNING: 'running',
9
- COMPLETED: 'completed',
10
- FAILED: 'failed',
11
- CANCELLED: 'cancelled',
12
- UNCERTAIN: 'uncertain',
13
- };
14
-
15
- export class DurableJournal {
16
- constructor(store) {
17
- this.store = store;
18
- }
19
-
20
- /**
21
- * Generates a deterministic idempotency key for a task run
22
- */
23
- generateIdempotencyKey(taskId, scheduleTick) {
24
- const raw = `${taskId}:${scheduleTick || Date.now()}`;
25
- return 'idem_' + createHash('sha256').update(raw).digest('hex').slice(0, 24);
26
- }
27
-
28
- /**
29
- * Checks whether a run with this idempotency key was already completed
30
- */
31
- isIdempotencyKeyExecuted(taskId, idempotencyKey) {
32
- if (!idempotencyKey) return null;
33
- const history = this.store.getHistory(taskId) || [];
34
- return history.find(
35
- (r) => r.idempotencyKey === idempotencyKey &&
36
- (r.status === JOURNAL_STATES.COMPLETED || r.status === 'success')
37
- ) || null;
38
- }
39
-
40
- /**
41
- * Pre-allocates a durable run entry before execution starts (#317)
42
- */
43
- createRunRecord(taskId, { idempotencyKey, chainRunId, trigger = 'scheduler', scheduleTick } = {}) {
44
- const runId = 'run_' + randomUUID().replace(/-/g, '').slice(0, 16);
45
- const key = idempotencyKey || this.generateIdempotencyKey(taskId, scheduleTick);
46
-
47
- const record = {
48
- id: runId,
49
- taskId,
50
- at: Date.now(),
51
- status: JOURNAL_STATES.QUEUED,
52
- trigger,
53
- idempotencyKey: key,
54
- chainRunId: chainRunId || null,
55
- stages: [
56
- { name: 'queue', status: 'success', startedAt: Date.now(), finishedAt: Date.now() },
57
- { name: 'preflight', status: 'pending' },
58
- { name: 'execution', status: 'pending' },
59
- { name: 'validation', status: 'pending' },
60
- { name: 'delivery', status: 'pending' },
61
- ],
62
- stoppedReason: null,
63
- createdAt: Date.now(),
64
- };
65
-
66
- // Record initial queued entry into history/store
67
- this.store.recordRun(taskId, record);
68
- return record;
69
- }
70
-
71
- /**
72
- * Transitions a run record to a new state and updates history entry
73
- */
74
- transition(taskId, runId, toState, metadata = {}) {
75
- const runs = this.store.history.get(taskId) || [];
76
- const index = runs.findIndex((r) => r.id === runId);
77
- if (index === -1) return null;
78
-
79
- const existing = runs[index];
80
- const updated = {
81
- ...existing,
82
- ...metadata,
83
- status: toState,
84
- updatedAt: Date.now(),
85
- };
86
-
87
- if (
88
- toState === JOURNAL_STATES.COMPLETED ||
89
- toState === JOURNAL_STATES.FAILED ||
90
- toState === JOURNAL_STATES.CANCELLED ||
91
- toState === JOURNAL_STATES.UNCERTAIN
92
- ) {
93
- updated.finishedAt = Date.now();
94
- if (updated.startedAt && !updated.durationMs) {
95
- updated.durationMs = updated.finishedAt - updated.startedAt;
96
- }
97
- }
98
-
99
- runs[index] = updated;
100
- this.store.history.set(taskId, runs);
101
- this.store.scheduleSave(50);
102
- return updated;
103
- }
104
-
105
- /**
106
- * Recovers uncompleted / in-flight runs on daemon restart (#317)
107
- */
108
- recoverPendingRuns(scheduler) {
109
- const tasks = this.store.list();
110
- const recovered = { uncertain: [], requeued: [] };
111
-
112
- for (const task of tasks) {
113
- const history = this.store.history.get(task.id) || [];
114
- let historyChanged = false;
115
-
116
- for (let i = 0; i < history.length; i++) {
117
- const run = history[i];
118
- if (run.status === JOURNAL_STATES.RUNNING) {
119
- // Crash during execution -> transition to UNCERTAIN to prevent blind rerun
120
- history[i] = {
121
- ...run,
122
- status: JOURNAL_STATES.UNCERTAIN,
123
- stoppedReason: 'interrupted_by_restart',
124
- uncertainAt: Date.now(),
125
- };
126
- historyChanged = true;
127
- recovered.uncertain.push({ taskId: task.id, runId: run.id });
128
- } else if (run.status === JOURNAL_STATES.QUEUED) {
129
- // Queued run that didn't start yet: if task is still active and recent (<1 hour), re-enqueue
130
- const ageMs = Date.now() - (run.createdAt || run.at);
131
- if (task.status === 'active' && ageMs < 3600000 && scheduler && typeof scheduler.runTask === 'function') {
132
- scheduler.queue = scheduler.queue || [];
133
- if (!scheduler.queue.includes(task.id)) {
134
- scheduler.queue.push(task.id);
135
- recovered.requeued.push({ taskId: task.id, runId: run.id });
136
- }
137
- } else {
138
- // Expired or cancelled queued run
139
- history[i] = {
140
- ...run,
141
- status: JOURNAL_STATES.CANCELLED,
142
- stoppedReason: 'Queued run expired or cancelled across restart',
143
- };
144
- historyChanged = true;
145
- }
146
- }
147
- }
148
-
149
- if (historyChanged) {
150
- this.store.scheduleSave(50);
151
- }
152
- }
153
-
154
- return recovered;
155
- }
156
-
157
- /**
158
- * Manually resolves an uncertain run record (#317)
159
- */
160
- resolveUncertain(taskId, runId, { decision, note, resolvedBy = 'user' } = {}) {
161
- const runs = this.store.history.get(taskId) || [];
162
- const index = runs.findIndex((r) => r.id === runId);
163
- if (index === -1) return { ok: false, error: 'Run not found' };
164
-
165
- const run = runs[index];
166
- if (run.status !== JOURNAL_STATES.UNCERTAIN) {
167
- return { ok: false, error: `Run is in status "${run.status}", not uncertain` };
168
- }
169
-
170
- let nextStatus = JOURNAL_STATES.FAILED;
171
- if (decision === 'completed' || decision === 'mark_completed') nextStatus = JOURNAL_STATES.COMPLETED;
172
- else if (decision === 'failed' || decision === 'mark_failed') nextStatus = JOURNAL_STATES.FAILED;
173
- else if (decision === 'cancel' || decision === 'cancelled') nextStatus = JOURNAL_STATES.CANCELLED;
174
-
175
- const resolved = {
176
- ...run,
177
- status: nextStatus,
178
- stoppedReason: `Uncertain run resolved by ${resolvedBy}: ${note || decision}`,
179
- resolvedBy,
180
- resolvedAt: Date.now(),
181
- };
182
-
183
- runs[index] = resolved;
184
- this.store.history.set(taskId, runs);
185
- this.store.scheduleSave(50);
186
- return { ok: true, run: resolved };
187
- }
188
- }
1
+ export * from './store/durable-journal.js';