@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
@@ -1,180 +1 @@
1
- import z from '@deepseek-ai/schemastery';
2
- import { normalizeTaskType } from './runtimes.js';
3
- import { validateTaskType, PATCHABLE_TASK_FIELDS } from './task-transfer.js';
4
- import { CHANNEL_IDS } from './channels.js';
5
-
6
- export const TaskSchema = z.object({
7
- id: z.string(),
8
- title: z.string().required(),
9
- schedule: z.string().required(),
10
- scheduleText: z.string(),
11
- type: z.string().default('llm'),
12
- prompt: z.string().default(''),
13
- delivery: z.string().default('isolated'),
14
- status: z.string().default('active'),
15
- provider: z.string(),
16
- model: z.string(),
17
- fallbackProvider: z.string(),
18
- fallbackModel: z.string(),
19
- silentRule: z.string().default(''),
20
- inspectOnFailure: z.boolean().default(false),
21
- agentPreset: z.string().default(''),
22
- targetSessionId: z.string().default(''),
23
- targetSessionReset: z.string().default('never'),
24
- sessionTitle: z.string().default(''),
25
- timezone: z.string().default(''),
26
- misfirePolicy: z.string().default('skip'),
27
- maxRetries: z.number().default(0),
28
- retryBackoffMs: z.number().default(30000),
29
- permissionPreset: z.string().default('default'),
30
- captureGitDiff: z.boolean(),
31
- kanbanMode: z.string().default('none'),
32
- channels: z.array(z.string()).default([]),
33
- template: z.string().default(''),
34
- env: z.any(),
35
- cwd: z.string().default(''),
36
- workspaceId: z.string().default(''),
37
- worktree: z.boolean().default(false),
38
- keepWorktree: z.boolean().default(false),
39
- httpMethod: z.string().default('GET'),
40
- httpUrl: z.string().default(''),
41
- httpHeaders: z.any(),
42
- httpBody: z.string().default(''),
43
- sshProfileId: z.string().default(''),
44
- sshTarget: z.string().default(''),
45
- sshPort: z.number().default(0),
46
- sshKeyPath: z.string().default(''),
47
- dockerImage: z.string().default(''),
48
- pythonPath: z.string().default(''),
49
- nodePath: z.string().default(''),
50
- skillName: z.string().default(''),
51
- workflowName: z.string().default(''),
52
- webhookEnabled: z.boolean().default(false),
53
- webhookToken: z.string(),
54
- webhookSecret: z.string(),
55
- webhookDebounceMs: z.number(),
56
- webhookTrigger: z.any(),
57
- onSuccess: z.string().default(''),
58
- onFailure: z.string().default(''),
59
- heartbeatIntervalSeconds: z.number().default(0),
60
- gracePeriodSeconds: z.number().default(300),
61
- preflightType: z.string().default('none'),
62
- preflightTarget: z.string().default(''),
63
- preflightCommand: z.string(),
64
- preflightScript: z.string(),
65
- concurrencyGroup: z.string().default('default'),
66
- priority: z.number().default(5),
67
- selfHealingCommand: z.string().default(''),
68
- autoDiagnose: z.boolean().default(false),
69
- notifyTelegram: z.boolean(),
70
- onlyOnFailure: z.boolean(),
71
- timeoutSeconds: z.number().default(1800),
72
- overlapPolicy: z.string().default('skip'),
73
- oneShot: z.boolean().default(false),
74
- targetTimestamp: z.number(),
75
- nextRunAt: z.number(),
76
- costLimitUsd: z.number(),
77
- dailyCostLimitUsd: z.number(),
78
- tokenLimit: z.number(),
79
- pausedReason: z.string(),
80
- validators: z.any(),
81
- requireApproval: z.boolean().default(false),
82
- fileWatch: z.any(),
83
- dependsOn: z.array(z.string()).default([]),
84
- joinPolicy: z.string().default('all_success'),
85
- adaptive: z.any(),
86
- adaptiveState: z.any(),
87
- promptVersion: z.number(),
88
- promptHistory: z.any(),
89
- branchRules: z.any(),
90
- elseTask: z.string(),
91
- autoHeal: z.boolean(),
92
- selfHealing: z.any(),
93
- revisions: z.any(),
94
- previousPrompt: z.string(),
95
- previousScript: z.string(),
96
- swarm: z.any(),
97
- gitDiff: z.any(),
98
- maxMemoryMb: z.number().min(0).default(0),
99
- executionTimeoutMs: z.number().min(0).default(900000),
100
- maxLogBytesPerRun: z.number().min(0).default(5 * 1024 * 1024),
101
- cpus: z.number().min(0).default(0),
102
- maxArchiveMonths: z.number().min(1).default(6),
103
- schemaVersion: z.number().default(2),
104
- createdAt: z.number(),
105
- updatedAt: z.number(),
106
- });
107
-
108
- export function validateTaskRecord(data) {
109
- if (!data || typeof data !== 'object') {
110
- throw new TypeError('Task record must be an object');
111
- }
112
- if (data.title !== undefined && typeof data.title !== 'string') {
113
- throw new Error('Task schema validation failed: title must be a string');
114
- }
115
- const title = String(data.title || '').trim();
116
- if (!title) {
117
- throw new Error('Task title cannot be empty');
118
- }
119
- if (data.schedule !== undefined && typeof data.schedule !== 'string') {
120
- throw new Error('Task schema validation failed: schedule must be a string');
121
- }
122
- const schedule = String(data.schedule || '').trim();
123
- if (!schedule) {
124
- throw new Error('Task schedule cannot be empty');
125
- }
126
-
127
- const cleanType = normalizeTaskType(data.type);
128
- const typeError = validateTaskType(cleanType, data);
129
- if (typeError) {
130
- throw new Error(typeError);
131
- }
132
-
133
- let validated;
134
- try {
135
- validated = TaskSchema({
136
- ...data,
137
- title,
138
- schedule,
139
- type: cleanType,
140
- });
141
- } catch (err) {
142
- throw new Error(`Task schema validation failed: ${err.message}`);
143
- }
144
-
145
- // Filter channels to known channel ids if present
146
- if (Array.isArray(validated.channels)) {
147
- validated.channels = validated.channels.filter((c) => CHANNEL_IDS.includes(c));
148
- }
149
-
150
- // Strip undefined keys
151
- for (const k of Object.keys(validated)) {
152
- if (validated[k] === undefined) {
153
- delete validated[k];
154
- }
155
- }
156
-
157
- return validated;
158
- }
159
-
160
- export function safeValidateTaskRecord(data) {
161
- try {
162
- const task = validateTaskRecord(data);
163
- return { ok: true, task };
164
- } catch (err) {
165
- return { ok: false, error: err.message };
166
- }
167
- }
168
-
169
- export function validateTaskPatch(patch) {
170
- if (!patch || typeof patch !== 'object') {
171
- return { ok: false, error: 'Task patch must be an object' };
172
- }
173
- const clean = {};
174
- for (const [key, value] of Object.entries(patch)) {
175
- if (PATCHABLE_TASK_FIELDS.includes(key)) {
176
- clean[key] = value;
177
- }
178
- }
179
- return { ok: true, patch: clean };
180
- }
1
+ export * from './store/task-schema.js';
@@ -1,310 +1 @@
1
- /**
2
- * Task transfer helpers: duplication, export and import (#41, #42).
3
- *
4
- * Pure functions over plain task objects with no I/O, so the whole
5
- * configuration-transfer contract is unit-testable and the HTTP layer stays a
6
- * thin adapter. Kept out of index.js both for cohesion and to keep that file
7
- * from growing further (see issue #97).
8
- */
9
-
10
- import { randomUUID } from 'node:crypto';
11
- import { normalizeTaskType } from './runtimes.js';
12
- import { CHANNEL_IDS } from './channels.js';
13
-
14
- /** Task fields a client may patch; the single source of truth below. */
15
- export const PATCHABLE_TASK_FIELDS = [
16
- 'title',
17
- 'schedule',
18
- 'scheduleText',
19
- 'prompt',
20
- 'type',
21
- 'delivery',
22
- 'provider',
23
- 'model',
24
- 'fallbackProvider',
25
- 'fallbackModel',
26
- 'silentRule',
27
- 'inspectOnFailure',
28
- 'timezone',
29
- 'misfirePolicy',
30
- 'maxRetries',
31
- 'retryBackoffMs',
32
- 'permissionPreset',
33
- 'agentPreset',
34
- 'targetSessionId',
35
- 'targetSessionReset',
36
- 'sessionTitle',
37
- 'webhookEnabled',
38
- 'webhookToken',
39
- 'webhookSecret',
40
- 'webhookDebounceMs',
41
- 'webhookTrigger',
42
- 'env',
43
- 'cwd',
44
- 'workspaceId',
45
- 'worktree',
46
- 'keepWorktree',
47
- 'captureGitDiff',
48
- 'httpMethod',
49
- 'httpUrl',
50
- 'httpHeaders',
51
- 'httpBody',
52
- 'sshTarget',
53
- 'sshPort',
54
- 'sshKeyPath',
55
- 'sshProfileId',
56
- 'dockerImage',
57
- 'pythonPath',
58
- 'nodePath',
59
- 'skillName',
60
- 'workflowName',
61
- 'notifyTelegram',
62
- 'onlyOnFailure',
63
- 'timeoutSeconds',
64
- 'overlapPolicy',
65
- 'kanbanMode',
66
- 'onSuccess',
67
- 'onFailure',
68
- 'heartbeatIntervalSeconds',
69
- 'gracePeriodSeconds',
70
- 'preflightType',
71
- 'preflightTarget',
72
- 'concurrencyGroup',
73
- 'priority',
74
- 'selfHealingCommand',
75
- 'autoDiagnose',
76
- 'channels',
77
- 'template',
78
- 'status',
79
- 'oneShot',
80
- 'costLimitUsd',
81
- 'dailyCostLimitUsd',
82
- 'tokenLimit',
83
- 'pausedReason',
84
- 'validators',
85
- 'requireApproval',
86
- 'fileWatch',
87
- 'dependsOn',
88
- 'joinPolicy',
89
- 'adaptive',
90
- 'adaptiveState',
91
- 'promptVersion',
92
- 'promptHistory',
93
- 'branchRules',
94
- 'elseTask',
95
- 'autoHeal',
96
- 'selfHealing',
97
- 'revisions',
98
- 'previousPrompt',
99
- 'previousScript',
100
- 'swarm',
101
- 'maxMemoryMb',
102
- 'executionTimeoutMs',
103
- 'maxLogBytesPerRun',
104
- 'cpus',
105
- 'maxArchiveMonths',
106
- 'gitDiff',
107
- ];
108
-
109
- /** Fields a duplicate inherits; anything else is runtime state, not config. */
110
- export const DUPLICATE_TASK_FIELDS = PATCHABLE_TASK_FIELDS.filter((key) => key !== 'status' && key !== 'pausedReason');
111
-
112
- /**
113
- * Task ids that would shadow the collection routes under /dsh-cron/tasks.
114
- * A task may not use them, otherwise it could never be fetched, paused or
115
- * deleted again (#42 review finding).
116
- */
117
- export const RESERVED_TASK_IDS = ['export', 'import'];
118
-
119
- /**
120
- * Task-level fields that can carry a secret the user typed in by hand
121
- * (`env` values, authorization headers). The export includes them, so the UI
122
- * and docs must say so instead of promising a secret-free file.
123
- */
124
- export const EXPORT_SECRET_BEARING_FIELDS = ['env', 'httpHeaders', 'httpBody'];
125
-
126
- /**
127
- * Validate runtime-specific task configuration. Returns an error string or
128
- * null when the payload is usable for the given type.
129
- */
130
- export function validateTaskType(type, payload) {
131
- const t = normalizeTaskType(type);
132
- if (t === 'http') {
133
- const raw = String((payload && (payload.httpUrl || payload.prompt)) || '').trim();
134
- if (!raw) return 'HTTP tasks require a URL (httpUrl or prompt)';
135
- try {
136
- // eslint-disable-next-line no-new
137
- new URL(raw);
138
- } catch {
139
- return `Invalid HTTP URL: ${raw}`;
140
- }
141
- }
142
- if (t === 'ssh') {
143
- const hasProfile = Boolean(payload && String(payload.sshProfileId || '').trim());
144
- const hasTarget = Boolean(payload && String(payload.sshTarget || '').trim());
145
- if (!hasProfile && !hasTarget) return 'SSH tasks require sshProfileId (remote-workspace profile) or sshTarget (user@host)';
146
- }
147
- if (t === 'docker' && !String((payload && payload.dockerImage) || '').trim()) {
148
- return 'Docker tasks require dockerImage';
149
- }
150
- return null;
151
- }
152
-
153
- /**
154
- * Build a paused copy of a task (#41).
155
- *
156
- * Configuration is copied, execution state is not: the copy starts paused with
157
- * no history, no token/cost totals, no last-run info and no retry counter, so a
158
- * duplicate can be reviewed before it ever runs. A duplicated one-shot keeps
159
- * its original schedule string and stays inert until the user resumes it.
160
- */
161
- export function buildDuplicateTask(source, { id } = {}) {
162
- const copy = {
163
- id: id || randomUUID(),
164
- title: `${source.title || 'Task'} (copy)`,
165
- status: 'paused',
166
- createdAt: Date.now(),
167
- updatedAt: Date.now(),
168
- };
169
- for (const key of DUPLICATE_TASK_FIELDS) {
170
- if (key === 'title') continue;
171
- if (source[key] !== undefined) copy[key] = Array.isArray(source[key]) ? [...source[key]] : source[key];
172
- }
173
- return copy;
174
- }
175
-
176
- // ---------------------------------------------------- export / import (#42)
177
-
178
- /** Document kind marker so a random JSON file is not mistaken for an export. */
179
- export const TASK_EXPORT_KIND = 'dsh-cron-tasks';
180
- export const TASK_EXPORT_VERSION = 1;
181
-
182
- /** Fields an exported task carries: identity + configuration, never run state. */
183
- export const EXPORT_TASK_FIELDS = ['id', 'title', ...DUPLICATE_TASK_FIELDS.filter((k) => k !== 'title')];
184
-
185
- /** Import strategies: add (new ids), replace (overwrite by id), skip (leave existing). */
186
- export const IMPORT_STRATEGIES = ['add', 'replace', 'skip'];
187
-
188
- /**
189
- * Serialise tasks for transfer/backup. Run state (history, token and cost
190
- * totals, last-run info) is deliberately excluded — the file is a
191
- * configuration artefact. Channels reference credentials by name, but task-level
192
- * `env` and headers can contain hand-typed secrets, so the caller must not
193
- * claim the file is secret-free.
194
- */
195
- export function buildTaskExport(tasks, { now = Date.now(), sanitize = false } = {}) {
196
- const list = Array.isArray(tasks) ? tasks : [];
197
- return {
198
- kind: TASK_EXPORT_KIND,
199
- version: TASK_EXPORT_VERSION,
200
- exportedAt: new Date(now).toISOString(),
201
- sanitized: Boolean(sanitize),
202
- tasks: list.map((task) => {
203
- const out = {};
204
- for (const key of EXPORT_TASK_FIELDS) {
205
- if (task[key] === undefined || key === 'status') continue;
206
- if (sanitize && EXPORT_SECRET_BEARING_FIELDS.includes(key)) continue;
207
- out[key] = Array.isArray(task[key]) ? [...task[key]] : task[key];
208
- }
209
- return out;
210
- }),
211
- };
212
- }
213
-
214
- /**
215
- * Validate an import document and return sanitised tasks.
216
- *
217
- * An import file is external input, so it is treated as untrusted: only
218
- * whitelisted configuration fields survive, the file's `status` is ignored
219
- * (imported tasks always start paused, matching the documented behaviour), and
220
- * a document that would shadow a collection route id is refused. A malformed
221
- * file is rejected as a whole so nothing is written.
222
- */
223
- export function validateImportDocument(doc) {
224
- if (!doc || typeof doc !== 'object') return { ok: false, error: 'Import file is not a JSON object' };
225
- if (doc.kind !== TASK_EXPORT_KIND) return { ok: false, error: `Not a ${TASK_EXPORT_KIND} export (missing kind marker)` };
226
- if (Number(doc.version) > TASK_EXPORT_VERSION) {
227
- return { ok: false, error: `Export version ${doc.version} is newer than this plugin understands (${TASK_EXPORT_VERSION})` };
228
- }
229
- if (!Array.isArray(doc.tasks)) return { ok: false, error: 'Export has no tasks array' };
230
- const tasks = [];
231
- for (const [index, raw] of doc.tasks.entries()) {
232
- if (!raw || typeof raw !== 'object') return { ok: false, error: `Task #${index + 1} is not an object` };
233
- if (!raw.title || !String(raw.title).trim()) return { ok: false, error: `Task #${index + 1} has no title` };
234
- if (!raw.schedule || !String(raw.schedule).trim()) return { ok: false, error: `Task #${index + 1} has no schedule` };
235
- if (!raw.prompt || !String(raw.prompt).trim()) return { ok: false, error: `Task #${index + 1} has no prompt` };
236
- const id = raw.id === undefined ? undefined : String(raw.id);
237
- if (id && RESERVED_TASK_IDS.includes(id)) {
238
- return { ok: false, error: `Task #${index + 1} uses the reserved id "${id}" which would shadow a task route` };
239
- }
240
- const type = normalizeTaskType(raw.type);
241
- const typeError = validateTaskType(type, raw);
242
- if (typeError) return { ok: false, error: `Task #${index + 1} ("${raw.title}"): ${typeError}` };
243
- const channels = Array.isArray(raw.channels) ? raw.channels.filter((c) => CHANNEL_IDS.includes(c)) : undefined;
244
- // Whitelist: drop anything the file tried to smuggle in (counters, history
245
- // pointers, unknown keys) and never inherit the file's status.
246
- const clean = { status: 'paused', type };
247
- for (const key of DUPLICATE_TASK_FIELDS) {
248
- if (raw[key] !== undefined) clean[key] = Array.isArray(raw[key]) ? [...raw[key]] : raw[key];
249
- }
250
- if (clean.env && typeof clean.env === 'object') {
251
- const filteredEnv = {};
252
- for (const [k, v] of Object.entries(clean.env)) {
253
- if (typeof v === 'string' && (v.includes('••••') || v.trim() === '********')) {
254
- continue; // Masked secret placeholder from UI/doc must never become literal secret (#240)
255
- }
256
- filteredEnv[k] = v;
257
- }
258
- clean.env = filteredEnv;
259
- }
260
- if (clean.httpHeaders && typeof clean.httpHeaders === 'object') {
261
- const filteredHeaders = {};
262
- for (const [k, v] of Object.entries(clean.httpHeaders)) {
263
- if (typeof v === 'string' && (v.includes('••••') || v.trim() === '********')) {
264
- continue;
265
- }
266
- filteredHeaders[k] = v;
267
- }
268
- clean.httpHeaders = filteredHeaders;
269
- }
270
- if (id) clean.id = id;
271
- if (channels) clean.channels = channels;
272
- tasks.push(clean);
273
- }
274
- return { ok: true, tasks };
275
- }
276
-
277
- /** True when a task executes host code or contains shell hooks (#212). */
278
- export function isCodeExecutingTask(task) {
279
- if (!task || typeof task !== 'object') return false;
280
- const CODE_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
281
- if (CODE_TYPES.includes(normalizeTaskType(task.type))) return true;
282
- if (task.command && String(task.command).trim()) return true;
283
- if (task.preflightType === 'command' || task.preflightType === 'shell' || (task.preflightCommand && String(task.preflightCommand).trim())) return true;
284
- if (task.selfHealingCommand && String(task.selfHealingCommand).trim()) return true;
285
- if (task.preflightScript && String(task.preflightScript).trim()) return true;
286
- return false;
287
- }
288
-
289
- /** True when any task in a validated batch executes code on the host (#212). */
290
- export function hasCodeExecutingTask(tasks) {
291
- return (tasks || []).some(isCodeExecutingTask);
292
- }
293
-
294
- /**
295
- * Decide what an import would do, without touching anything. Existing ids are
296
- * compared against tasks already in the store.
297
- */
298
- export function planImport(tasks, existingIds, strategy = 'skip') {
299
- const mode = IMPORT_STRATEGIES.includes(strategy) ? strategy : 'skip';
300
- const existing = new Set(existingIds || []);
301
- const plan = { strategy: mode, add: [], replace: [], skip: [] };
302
- for (const task of tasks || []) {
303
- const hasId = Boolean(task.id) && existing.has(task.id);
304
- if (!hasId) plan.add.push(task);
305
- else if (mode === 'replace') plan.replace.push(task);
306
- else if (mode === 'skip') plan.skip.push(task);
307
- else plan.add.push({ ...task, id: undefined });
308
- }
309
- return plan;
310
- }
1
+ export * from './store/task-transfer.js';
@@ -0,0 +1,157 @@
1
+ /**
2
+ * dsh-cron TUI settings section (#387).
3
+ *
4
+ * Exposes dsh-cron configuration to the terminal settings screen via
5
+ * the host tuiSettingsSections seam.
6
+ */
7
+
8
+ function numFormat(val, defaultVal = '0') {
9
+ return Number.isFinite(Number(val)) ? String(Number(val)) : defaultVal;
10
+ }
11
+
12
+ function numParse(text, min = 0) {
13
+ const s = String(text ?? '').trim();
14
+ if (s === '') return { kind: 'clear' };
15
+ const v = Number(s);
16
+ return Number.isFinite(v) && v >= min ? { kind: 'set', value: v } : { kind: 'clear' };
17
+ }
18
+
19
+ function textFormat(val) {
20
+ return String(val || '');
21
+ }
22
+
23
+ function textParse(text) {
24
+ const v = String(text || '').trim();
25
+ return v === '' ? { kind: 'clear' } : { kind: 'set', value: v };
26
+ }
27
+
28
+ /**
29
+ * Build declarative settings section for DSH TUI.
30
+ */
31
+ export function buildTuiSection({ live, store }) {
32
+ const current = typeof live === 'function' ? live() : (store?.getSettings?.() || {});
33
+
34
+ const fields = [
35
+ // 1. Delivery & Alerts
36
+ {
37
+ path: ['botToken'],
38
+ group: 'delivery',
39
+ kind: 'text',
40
+ label: 'Telegram Bot Token',
41
+ secret: { ref: current.botTokenRef || 'TELEGRAM_BOT_TOKEN' },
42
+ hint: 'Stored securely in credential store, never in plain config files.',
43
+ },
44
+ {
45
+ path: ['botTokenRef'],
46
+ group: 'delivery',
47
+ kind: 'text',
48
+ label: 'Bot Token Credential Ref',
49
+ placeholder: 'TELEGRAM_BOT_TOKEN',
50
+ hint: 'Name of the credential holding the Telegram bot token.',
51
+ format: textFormat,
52
+ parse: textParse,
53
+ },
54
+ {
55
+ path: ['chatId'],
56
+ group: 'delivery',
57
+ kind: 'text',
58
+ label: 'Telegram Chat ID',
59
+ placeholder: '-1001234567890',
60
+ hint: 'Chat ID for task execution reports and alerts.',
61
+ format: textFormat,
62
+ parse: textParse,
63
+ },
64
+ {
65
+ path: ['discordWebhookUrl'],
66
+ group: 'delivery',
67
+ kind: 'text',
68
+ label: 'Discord Webhook URL',
69
+ placeholder: 'https://discord.com/api/webhooks/...',
70
+ format: textFormat,
71
+ parse: textParse,
72
+ },
73
+ {
74
+ path: ['slackWebhookUrl'],
75
+ group: 'delivery',
76
+ kind: 'text',
77
+ label: 'Slack Webhook URL',
78
+ placeholder: 'https://hooks.slack.com/services/...',
79
+ format: textFormat,
80
+ parse: textParse,
81
+ },
82
+
83
+ // 2. Scheduler & Concurrency
84
+ {
85
+ path: ['defaultTimezone'],
86
+ group: 'scheduler',
87
+ kind: 'text',
88
+ label: 'Default Timezone',
89
+ placeholder: 'UTC',
90
+ hint: 'Default IANA timezone for schedules (e.g. Europe/Moscow, America/New_York, UTC).',
91
+ format: textFormat,
92
+ parse: textParse,
93
+ },
94
+ {
95
+ path: ['maxConcurrent'],
96
+ group: 'scheduler',
97
+ kind: 'number',
98
+ label: 'Max Concurrent Tasks',
99
+ hint: 'Maximum parallel task runs across all schedules (default 2, 0 = unlimited).',
100
+ format: (val) => numFormat(val, '2'),
101
+ parse: (text) => numParse(text, 0),
102
+ },
103
+
104
+ // 3. Budgets & Guardrails
105
+ {
106
+ path: ['globalDailyCostLimitUsd'],
107
+ group: 'budgets',
108
+ kind: 'number',
109
+ label: 'Daily Cost Limit (USD)',
110
+ hint: 'Global 24h expenditure limit in USD across all tasks (0 = unlimited).',
111
+ format: (val) => numFormat(val, '0'),
112
+ parse: (text) => numParse(text, 0),
113
+ },
114
+ ];
115
+
116
+ return {
117
+ ns: 'dsh-cron',
118
+ title: 'Cron Automations',
119
+ descriptions: {
120
+ en: 'Scheduled tasks, background automation, and notification guardrails.',
121
+ zh: '定时任务、后台自动化与成本防护。',
122
+ },
123
+ groups: [
124
+ { id: 'delivery', title: 'Delivery & Alerts', descriptions: { zh: '推送与告警' } },
125
+ { id: 'scheduler', title: 'Scheduler & Concurrency', descriptions: { zh: '调度与并发' } },
126
+ { id: 'budgets', title: 'Budgets & Guardrails', descriptions: { zh: '预算与防护' } },
127
+ ],
128
+ fields,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Register TUI settings section if tuiSettingsSections service is provided.
134
+ */
135
+ export function applyTuiSettings(ctx, deps) {
136
+ if (typeof ctx?.inject !== 'function') return;
137
+ ctx.inject(['tuiSettingsSections'], (tuiCtx) => {
138
+ let service;
139
+ try {
140
+ service = tuiCtx?.tuiSettingsSections || tuiCtx?.get?.('tuiSettingsSections');
141
+ } catch {
142
+ service = undefined;
143
+ }
144
+ if (!service || typeof service.register !== 'function') return;
145
+ try {
146
+ const section = buildTuiSection(deps || {});
147
+ const dispose = service.register(section);
148
+ if (typeof ctx.effect === 'function') {
149
+ ctx.effect(() => () => {
150
+ try { dispose?.(); } catch { /* explicit best-effort cleanup */ }
151
+ }, 'dsh-cron: tui settings');
152
+ }
153
+ } catch (error) {
154
+ ctx?.logger?.warn?.('[dsh-cron] Could not register TUI settings: ' + (error?.message || error));
155
+ }
156
+ });
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "files": [
14
14
  "lib/*.js",
15
+ "lib/**/*.js",
15
16
  "lib/client.css",
16
17
  "cordis.patch.yml",
17
18
  "README.md",