@goodandready/dsh-cron 0.2.4 → 0.2.6
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/README.md +27 -21
- package/docs/README.ru.md +31 -20
- package/docs/README.zh.md +28 -21
- package/docs/design/DESIGN.md +13 -7
- package/docs/plans/0.2.5-ui-block.md +50 -0
- package/docs/plans/0.2.6-economy-block.md +61 -0
- package/lib/api.js +399 -0
- package/lib/channels.js +51 -61
- package/lib/client.js +727 -44
- package/lib/failure-inspector.js +107 -0
- package/lib/http-utils.js +24 -0
- package/lib/index.js +171 -359
- package/lib/llm-ask.js +141 -0
- package/lib/recipes.js +238 -0
- package/lib/runner.js +210 -171
- package/lib/scheduler.js +226 -80
- package/lib/silent-rule.js +100 -0
- package/lib/store.js +16 -5
- package/lib/task-patch.js +81 -0
- package/lib/task-transfer.js +230 -0
- package/lib/templates.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,230 @@
|
|
|
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
|
+
'env',
|
|
34
|
+
'cwd',
|
|
35
|
+
'workspaceId',
|
|
36
|
+
'worktree',
|
|
37
|
+
'keepWorktree',
|
|
38
|
+
'httpMethod',
|
|
39
|
+
'httpUrl',
|
|
40
|
+
'httpHeaders',
|
|
41
|
+
'httpBody',
|
|
42
|
+
'sshTarget',
|
|
43
|
+
'sshPort',
|
|
44
|
+
'sshKeyPath',
|
|
45
|
+
'sshProfileId',
|
|
46
|
+
'dockerImage',
|
|
47
|
+
'pythonPath',
|
|
48
|
+
'nodePath',
|
|
49
|
+
'skillName',
|
|
50
|
+
'workflowName',
|
|
51
|
+
'notifyTelegram',
|
|
52
|
+
'onlyOnFailure',
|
|
53
|
+
'timeoutSeconds',
|
|
54
|
+
'overlapPolicy',
|
|
55
|
+
'kanbanMode',
|
|
56
|
+
'channels',
|
|
57
|
+
'template',
|
|
58
|
+
'status',
|
|
59
|
+
'oneShot',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Fields a duplicate inherits; anything else is runtime state, not config. */
|
|
63
|
+
export const DUPLICATE_TASK_FIELDS = PATCHABLE_TASK_FIELDS.filter((key) => key !== 'status');
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Task ids that would shadow the collection routes under /dsh-cron/tasks.
|
|
67
|
+
* A task may not use them, otherwise it could never be fetched, paused or
|
|
68
|
+
* deleted again (#42 review finding).
|
|
69
|
+
*/
|
|
70
|
+
export const RESERVED_TASK_IDS = ['export', 'import'];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Task-level fields that can carry a secret the user typed in by hand
|
|
74
|
+
* (`env` values, authorization headers). The export includes them, so the UI
|
|
75
|
+
* and docs must say so instead of promising a secret-free file.
|
|
76
|
+
*/
|
|
77
|
+
export const EXPORT_SECRET_BEARING_FIELDS = ['env', 'httpHeaders', 'httpBody'];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validate runtime-specific task configuration. Returns an error string or
|
|
81
|
+
* null when the payload is usable for the given type.
|
|
82
|
+
*/
|
|
83
|
+
export function validateTaskType(type, payload) {
|
|
84
|
+
const t = normalizeTaskType(type);
|
|
85
|
+
if (t === 'http') {
|
|
86
|
+
const raw = String((payload && (payload.httpUrl || payload.prompt)) || '').trim();
|
|
87
|
+
if (!raw) return 'HTTP tasks require a URL (httpUrl or prompt)';
|
|
88
|
+
try {
|
|
89
|
+
// eslint-disable-next-line no-new
|
|
90
|
+
new URL(raw);
|
|
91
|
+
} catch {
|
|
92
|
+
return `Invalid HTTP URL: ${raw}`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (t === 'ssh') {
|
|
96
|
+
const hasProfile = Boolean(payload && String(payload.sshProfileId || '').trim());
|
|
97
|
+
const hasTarget = Boolean(payload && String(payload.sshTarget || '').trim());
|
|
98
|
+
if (!hasProfile && !hasTarget) return 'SSH tasks require sshProfileId (remote-workspace profile) or sshTarget (user@host)';
|
|
99
|
+
}
|
|
100
|
+
if (t === 'docker' && !String((payload && payload.dockerImage) || '').trim()) {
|
|
101
|
+
return 'Docker tasks require dockerImage';
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build a paused copy of a task (#41).
|
|
108
|
+
*
|
|
109
|
+
* Configuration is copied, execution state is not: the copy starts paused with
|
|
110
|
+
* no history, no token/cost totals, no last-run info and no retry counter, so a
|
|
111
|
+
* duplicate can be reviewed before it ever runs. A duplicated one-shot keeps
|
|
112
|
+
* its original schedule string and stays inert until the user resumes it.
|
|
113
|
+
*/
|
|
114
|
+
export function buildDuplicateTask(source, { id } = {}) {
|
|
115
|
+
const copy = {
|
|
116
|
+
id: id || randomUUID(),
|
|
117
|
+
title: `${source.title || 'Task'} (copy)`,
|
|
118
|
+
status: 'paused',
|
|
119
|
+
createdAt: Date.now(),
|
|
120
|
+
updatedAt: Date.now(),
|
|
121
|
+
};
|
|
122
|
+
for (const key of DUPLICATE_TASK_FIELDS) {
|
|
123
|
+
if (key === 'title') continue;
|
|
124
|
+
if (source[key] !== undefined) copy[key] = Array.isArray(source[key]) ? [...source[key]] : source[key];
|
|
125
|
+
}
|
|
126
|
+
return copy;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------- export / import (#42)
|
|
130
|
+
|
|
131
|
+
/** Document kind marker so a random JSON file is not mistaken for an export. */
|
|
132
|
+
export const TASK_EXPORT_KIND = 'dsh-cron-tasks';
|
|
133
|
+
export const TASK_EXPORT_VERSION = 1;
|
|
134
|
+
|
|
135
|
+
/** Fields an exported task carries: identity + configuration, never run state. */
|
|
136
|
+
export const EXPORT_TASK_FIELDS = ['id', 'title', ...DUPLICATE_TASK_FIELDS.filter((k) => k !== 'title')];
|
|
137
|
+
|
|
138
|
+
/** Import strategies: add (new ids), replace (overwrite by id), skip (leave existing). */
|
|
139
|
+
export const IMPORT_STRATEGIES = ['add', 'replace', 'skip'];
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Serialise tasks for transfer/backup. Run state (history, token and cost
|
|
143
|
+
* totals, last-run info) is deliberately excluded — the file is a
|
|
144
|
+
* configuration artefact. Channels reference credentials by name, but task-level
|
|
145
|
+
* `env` and headers can contain hand-typed secrets, so the caller must not
|
|
146
|
+
* claim the file is secret-free.
|
|
147
|
+
*/
|
|
148
|
+
export function buildTaskExport(tasks, { now = Date.now() } = {}) {
|
|
149
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
150
|
+
return {
|
|
151
|
+
kind: TASK_EXPORT_KIND,
|
|
152
|
+
version: TASK_EXPORT_VERSION,
|
|
153
|
+
exportedAt: new Date(now).toISOString(),
|
|
154
|
+
tasks: list.map((task) => {
|
|
155
|
+
const out = {};
|
|
156
|
+
for (const key of EXPORT_TASK_FIELDS) {
|
|
157
|
+
if (task[key] === undefined || key === 'status') continue;
|
|
158
|
+
out[key] = Array.isArray(task[key]) ? [...task[key]] : task[key];
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Validate an import document and return sanitised tasks.
|
|
167
|
+
*
|
|
168
|
+
* An import file is external input, so it is treated as untrusted: only
|
|
169
|
+
* whitelisted configuration fields survive, the file's `status` is ignored
|
|
170
|
+
* (imported tasks always start paused, matching the documented behaviour), and
|
|
171
|
+
* a document that would shadow a collection route id is refused. A malformed
|
|
172
|
+
* file is rejected as a whole so nothing is written.
|
|
173
|
+
*/
|
|
174
|
+
export function validateImportDocument(doc) {
|
|
175
|
+
if (!doc || typeof doc !== 'object') return { ok: false, error: 'Import file is not a JSON object' };
|
|
176
|
+
if (doc.kind !== TASK_EXPORT_KIND) return { ok: false, error: `Not a ${TASK_EXPORT_KIND} export (missing kind marker)` };
|
|
177
|
+
if (Number(doc.version) > TASK_EXPORT_VERSION) {
|
|
178
|
+
return { ok: false, error: `Export version ${doc.version} is newer than this plugin understands (${TASK_EXPORT_VERSION})` };
|
|
179
|
+
}
|
|
180
|
+
if (!Array.isArray(doc.tasks)) return { ok: false, error: 'Export has no tasks array' };
|
|
181
|
+
const tasks = [];
|
|
182
|
+
for (const [index, raw] of doc.tasks.entries()) {
|
|
183
|
+
if (!raw || typeof raw !== 'object') return { ok: false, error: `Task #${index + 1} is not an object` };
|
|
184
|
+
if (!raw.title || !String(raw.title).trim()) return { ok: false, error: `Task #${index + 1} has no title` };
|
|
185
|
+
if (!raw.schedule || !String(raw.schedule).trim()) return { ok: false, error: `Task #${index + 1} has no schedule` };
|
|
186
|
+
if (!raw.prompt || !String(raw.prompt).trim()) return { ok: false, error: `Task #${index + 1} has no prompt` };
|
|
187
|
+
const id = raw.id === undefined ? undefined : String(raw.id);
|
|
188
|
+
if (id && RESERVED_TASK_IDS.includes(id)) {
|
|
189
|
+
return { ok: false, error: `Task #${index + 1} uses the reserved id "${id}" which would shadow a task route` };
|
|
190
|
+
}
|
|
191
|
+
const type = normalizeTaskType(raw.type);
|
|
192
|
+
const typeError = validateTaskType(type, raw);
|
|
193
|
+
if (typeError) return { ok: false, error: `Task #${index + 1} ("${raw.title}"): ${typeError}` };
|
|
194
|
+
const channels = Array.isArray(raw.channels) ? raw.channels.filter((c) => CHANNEL_IDS.includes(c)) : undefined;
|
|
195
|
+
// Whitelist: drop anything the file tried to smuggle in (counters, history
|
|
196
|
+
// pointers, unknown keys) and never inherit the file's status.
|
|
197
|
+
const clean = { status: 'paused', type };
|
|
198
|
+
for (const key of DUPLICATE_TASK_FIELDS) {
|
|
199
|
+
if (raw[key] !== undefined) clean[key] = Array.isArray(raw[key]) ? [...raw[key]] : raw[key];
|
|
200
|
+
}
|
|
201
|
+
if (id) clean.id = id;
|
|
202
|
+
if (channels) clean.channels = channels;
|
|
203
|
+
tasks.push(clean);
|
|
204
|
+
}
|
|
205
|
+
return { ok: true, tasks };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** True when any task in a validated batch executes code on the host. */
|
|
209
|
+
export function hasCodeExecutingTask(tasks) {
|
|
210
|
+
const CODE_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
|
|
211
|
+
return (tasks || []).some((task) => CODE_TYPES.includes(normalizeTaskType(task.type)));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Decide what an import would do, without touching anything. Existing ids are
|
|
216
|
+
* compared against tasks already in the store.
|
|
217
|
+
*/
|
|
218
|
+
export function planImport(tasks, existingIds, strategy = 'skip') {
|
|
219
|
+
const mode = IMPORT_STRATEGIES.includes(strategy) ? strategy : 'skip';
|
|
220
|
+
const existing = new Set(existingIds || []);
|
|
221
|
+
const plan = { strategy: mode, add: [], replace: [], skip: [] };
|
|
222
|
+
for (const task of tasks || []) {
|
|
223
|
+
const hasId = Boolean(task.id) && existing.has(task.id);
|
|
224
|
+
if (!hasId) plan.add.push(task);
|
|
225
|
+
else if (mode === 'replace') plan.replace.push(task);
|
|
226
|
+
else if (mode === 'skip') plan.skip.push(task);
|
|
227
|
+
else plan.add.push({ ...task, id: undefined });
|
|
228
|
+
}
|
|
229
|
+
return plan;
|
|
230
|
+
}
|
package/lib/templates.js
CHANGED
|
@@ -17,6 +17,8 @@ export const TEMPLATE_VARIABLES = [
|
|
|
17
17
|
'time',
|
|
18
18
|
'tokens',
|
|
19
19
|
'cost',
|
|
20
|
+
'model',
|
|
21
|
+
'diagnosis',
|
|
20
22
|
];
|
|
21
23
|
|
|
22
24
|
export const DEFAULT_PLAIN_TEMPLATE = '⏰ {title}\nStatus: {status}\nSchedule: {schedule}\nDuration: {duration}\n{output}';
|
|
@@ -60,6 +62,8 @@ export function buildTemplateVars(task, runInfo = {}) {
|
|
|
60
62
|
time: new Date(runInfo.at || Date.now()).toISOString(),
|
|
61
63
|
tokens: String(tokens),
|
|
62
64
|
cost: `$${Number(runInfo.costUsd || 0).toFixed(4)}`,
|
|
65
|
+
model: runInfo.model || '',
|
|
66
|
+
diagnosis: truncateText(runInfo.diagnosis || ''),
|
|
63
67
|
};
|
|
64
68
|
}
|
|
65
69
|
|