@goodandready/dsh-cron 0.3.25 → 0.3.26

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