@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.
- 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 -191
- 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/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/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 +2 -1
|
@@ -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/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';
|
package/lib/external-api.js
CHANGED
|
@@ -1,74 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* External REST surface under /dsh-cron/api/* (#54, ADR-0001).
|
|
3
|
-
*
|
|
4
|
-
* Everything a browser panel needs stays on the local, cross-origin-protected
|
|
5
|
-
* routes; this prefix exists for CI and host-side automation, so it is the only
|
|
6
|
-
* surface guarded by a bearer token. The token lives in the plugin settings as
|
|
7
|
-
* a masked value: an unconfigured token disables the surface (503) instead of
|
|
8
|
-
* leaving it open, and a wrong token is answered with 401.
|
|
9
|
-
*
|
|
10
|
-
* The operations themselves are shared with the panel routes (lib/api.js), so
|
|
11
|
-
* the confirmation gate for code-executing tasks and the config-owned refusals
|
|
12
|
-
* apply here exactly as they do over HTTP from the UI.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { timingSafeEqual } from 'node:crypto';
|
|
16
|
-
import { sendJson } from './http-utils.js';
|
|
17
|
-
import { handleExternalTaskRequest } from './api.js';
|
|
18
|
-
|
|
19
|
-
/** Settings key that holds the bearer token. */
|
|
20
|
-
export const API_TOKEN_SETTING_KEY = 'apiToken';
|
|
21
|
-
|
|
22
|
-
/** Route prefix owned by this surface. */
|
|
23
|
-
export const EXTERNAL_API_PREFIX = '/dsh-cron/api';
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Constant-time token comparison. Length is compared first because
|
|
27
|
-
* timingSafeEqual throws on buffers of different sizes; a length mismatch is
|
|
28
|
-
* not a secret worth hiding for a fixed-length token.
|
|
29
|
-
*/
|
|
30
|
-
export function tokenMatches(expected, provided) {
|
|
31
|
-
if (!expected || !provided) return false;
|
|
32
|
-
const a = Buffer.from(String(expected), 'utf8');
|
|
33
|
-
const b = Buffer.from(String(provided), 'utf8');
|
|
34
|
-
if (a.length !== b.length) return false;
|
|
35
|
-
return timingSafeEqual(a, b);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Bearer token of a request, or an empty string when there is none. */
|
|
39
|
-
export function readBearerToken(req) {
|
|
40
|
-
const header = String((req.headers && req.headers.authorization) || '');
|
|
41
|
-
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
42
|
-
return match ? match[1].trim() : '';
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Guard the external prefix, then hand the request to the shared task
|
|
47
|
-
* operations. `getToken` is read per request so a settings change applies
|
|
48
|
-
* without a restart.
|
|
49
|
-
*/
|
|
50
|
-
export function createExternalApiHandler({ store, scheduler, getToken }) {
|
|
51
|
-
return async function handleExternalApi(req, res) {
|
|
52
|
-
const expected = typeof getToken === 'function' ? getToken() : '';
|
|
53
|
-
if (!expected) {
|
|
54
|
-
sendJson(res, 503, {
|
|
55
|
-
ok: false,
|
|
56
|
-
error: 'External API is disabled: set the "' + API_TOKEN_SETTING_KEY + '" setting to enable it',
|
|
57
|
-
});
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
if (!tokenMatches(expected, readBearerToken(req))) {
|
|
61
|
-
sendJson(res, 401, { ok: false, error: 'Unauthorized' });
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
const url = new URL(req.url, 'http://127.0.0.1');
|
|
65
|
-
// `url` must be inside the try: a malformed body (an unparseable schedule,
|
|
66
|
-
// for example) surfaces as a thrown error from the shared handlers, and an
|
|
67
|
-
// unhandled rejection here would answer nothing at all.
|
|
68
|
-
try {
|
|
69
|
-
await handleExternalTaskRequest({ store, scheduler, req, res, url, apiToken: expected });
|
|
70
|
-
} catch (err) {
|
|
71
|
-
sendJson(res, (err && err.statusCode) || 500, { ok: false, error: (err && err.message) || String(err) });
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
}
|
|
1
|
+
export * from './routes/external-api.js';
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { applyTuiSettings, buildTuiSection } from './tui-settings.js';
|
|
2
|
+
import { registerCommands, executeCronCommand } from './commands.js';
|
|
3
|
+
export { applyTuiSettings, buildTuiSection, registerCommands, executeCronCommand };
|
|
1
4
|
import {
|
|
2
5
|
TaskSchema,
|
|
3
6
|
validateTaskRecord,
|
|
@@ -376,6 +379,20 @@ export function apply(ctx, rawConfig) {
|
|
|
376
379
|
}), 'dsh-cron: /settings');
|
|
377
380
|
|
|
378
381
|
// 2. Tools: unified background automation runner (Issue #196: 9 tools -> 1)
|
|
382
|
+
// TUI settings section (#387)
|
|
383
|
+
applyTuiSettings(ctx, {
|
|
384
|
+
store,
|
|
385
|
+
scheduler,
|
|
386
|
+
live: () => ({
|
|
387
|
+
...unwrapConfig(getContextConfig(ctx, rawConfig)),
|
|
388
|
+
...activeVolatileConfig,
|
|
389
|
+
...store.getSettings(),
|
|
390
|
+
}),
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Slash commands (#388)
|
|
394
|
+
registerCommands(ctx, { store, scheduler });
|
|
395
|
+
|
|
379
396
|
ctx.tools.register(defineTool({
|
|
380
397
|
name: 'cron',
|
|
381
398
|
description: 'Background automation runner for DSH: isolated agent runs, script/node/python/http/ssh/docker runtimes, cost guard, notifications, heartbeats. For simple reminders in the current chat, use the built-in schedule_create tool instead. Actions: create, list, get, update, pause, resume, run, delete.',
|