@goodandready/dsh-cron 0.2.3 → 0.2.5
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 +69 -15
- package/docs/README.ru.md +69 -14
- package/docs/README.zh.md +74 -14
- package/docs/design/DESIGN.md +18 -8
- package/docs/plans/0.2.5-ui-block.md +50 -0
- package/lib/channels.js +412 -0
- package/lib/client.js +1134 -30
- package/lib/index.js +311 -38
- package/lib/integrations.js +3 -1
- package/lib/runner.js +185 -28
- package/lib/runtimes.js +174 -0
- package/lib/scheduler.js +66 -60
- package/lib/secrets.js +123 -0
- package/lib/store.js +91 -21
- package/lib/task-transfer.js +226 -0
- package/lib/telegram.js +20 -4
- package/lib/templates.js +88 -0
- package/package.json +1 -1
package/lib/runner.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { estimateTokenCost } from './integrations.js';
|
|
2
|
+
import {
|
|
3
|
+
EXTERNAL_RUNTIMES,
|
|
4
|
+
normalizeTaskType,
|
|
5
|
+
buildInvocation,
|
|
6
|
+
} from './runtimes.js';
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* Runner executing scheduled tasks in DeepSeek Harness sessions.
|
|
@@ -43,35 +48,164 @@ export class SessionRunner {
|
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the working directory for a task: explicit cwd, a bound
|
|
53
|
+
* workspace id resolved through the harness, or the process cwd (#31).
|
|
54
|
+
*/
|
|
55
|
+
resolveCwd(task) {
|
|
56
|
+
if (task.cwd) return String(task.cwd);
|
|
57
|
+
if (task.workspaceId && this.ctx && typeof this.ctx.get === 'function') {
|
|
58
|
+
try {
|
|
59
|
+
const workspaces = this.ctx.get('workspaces');
|
|
60
|
+
const ws = workspaces && (typeof workspaces.get === 'function'
|
|
61
|
+
? workspaces.get(task.workspaceId)
|
|
62
|
+
: (workspaces[task.workspaceId] || null));
|
|
63
|
+
const resolved = ws && (ws.path || ws.cwd || ws.root || ws.dir);
|
|
64
|
+
if (resolved) return String(resolved);
|
|
65
|
+
} catch {}
|
|
66
|
+
return String(task.workspaceId);
|
|
67
|
+
}
|
|
68
|
+
return process.cwd();
|
|
69
|
+
}
|
|
48
70
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
/** Create an isolated git worktree for a code-modifying task (#29). */
|
|
72
|
+
async createTaskWorktree(cwd, task) {
|
|
73
|
+
const { execFile } = await import('node:child_process');
|
|
74
|
+
const path = await import('node:path');
|
|
75
|
+
const wtPath = path.join(cwd, '.dsh-cron-worktrees', `${task.id}-${Date.now()}`);
|
|
76
|
+
await new Promise((resolve, reject) => {
|
|
77
|
+
execFile('git', ['-C', cwd, 'worktree', 'add', '--detach', wtPath, 'HEAD'], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
78
|
+
if (err) reject(new Error(String(stderr || err.message).trim()));
|
|
79
|
+
else resolve(stdout);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
return wtPath;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Remove a task worktree; never throws (best-effort cleanup).
|
|
87
|
+
* Plain removal only — no force flags anywhere (workspace rule). A dirty
|
|
88
|
+
* worktree is left in place and reported instead of being force-deleted.
|
|
89
|
+
*/
|
|
90
|
+
async removeTaskWorktree(cwd, wtPath) {
|
|
91
|
+
const { execFile } = await import('node:child_process');
|
|
92
|
+
await new Promise((resolve) => {
|
|
93
|
+
execFile('git', ['-C', cwd, 'worktree', 'remove', wtPath], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
94
|
+
if (err) {
|
|
95
|
+
console.warn(`[dsh-cron] task worktree kept (not removable without force): ${wtPath} — ${String(stderr || err.message).trim()}`);
|
|
96
|
+
}
|
|
97
|
+
execFile('git', ['-C', cwd, 'worktree', 'prune'], () => resolve());
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** HTTP trigger runtime (#7). */
|
|
103
|
+
async _runHttp(task, request, signal) {
|
|
104
|
+
const res = await fetch(request.url, {
|
|
105
|
+
method: request.method,
|
|
106
|
+
headers: request.headers,
|
|
107
|
+
body: request.body,
|
|
108
|
+
signal,
|
|
109
|
+
});
|
|
110
|
+
const text = await res.text().catch(() => '');
|
|
111
|
+
const summary = `${request.method} ${request.url} -> ${res.status} ${res.statusText || ''}`.trim();
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
throw new Error(`${summary}\n${String(text).slice(0, 500)}`);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
output: `${summary}\n${String(text).trim().slice(0, 4000)}`.trim(),
|
|
117
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
118
|
+
costUsd: 0,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* #9 reuse-first path: when the task names a dsh-remote-workspace profile,
|
|
124
|
+
* execute through that plugin's `remoteSsh` service so host settings and
|
|
125
|
+
* credentials stay owned by it (we only reference the profile id).
|
|
126
|
+
* Returns null when the service or the profile is unavailable so the caller
|
|
127
|
+
* can fall back to a direct ssh invocation.
|
|
128
|
+
*/
|
|
129
|
+
async _runViaRemoteWorkspace(task, signal) {
|
|
130
|
+
if (!task.sshProfileId || !this.ctx || typeof this.ctx.get !== 'function') return null;
|
|
131
|
+
let svc = null;
|
|
132
|
+
try {
|
|
133
|
+
svc = this.ctx.get('remoteSsh');
|
|
134
|
+
} catch {}
|
|
135
|
+
if (!svc || typeof svc.exec !== 'function') return null;
|
|
136
|
+
|
|
137
|
+
let profile = null;
|
|
138
|
+
try {
|
|
139
|
+
const settings = this.ctx.get('settings');
|
|
140
|
+
if (settings && typeof settings.get === 'function') {
|
|
141
|
+
const snap = settings.get('dsh-remote-workspace');
|
|
142
|
+
const profiles = snap && Array.isArray(snap.profiles) ? snap.profiles : [];
|
|
143
|
+
profile = profiles.find((p) => p && p.id === task.sshProfileId) || null;
|
|
144
|
+
}
|
|
145
|
+
} catch {}
|
|
146
|
+
if (!profile) {
|
|
147
|
+
throw new Error(`remote-workspace profile "${task.sshProfileId}" was not found — check dsh-remote-workspace settings, or use sshTarget instead`);
|
|
148
|
+
}
|
|
149
|
+
const res = await svc.exec(profile, String(task.prompt || 'true'), task.cwd || profile.remoteWorkspace || undefined);
|
|
150
|
+
const output = [res && res.stdout, res && res.stderr].filter((x) => x && String(x).trim()).join('\n').trim();
|
|
151
|
+
const code = res && typeof res.code === 'number' ? res.code : 0;
|
|
152
|
+
if (code !== 0) {
|
|
153
|
+
throw new Error(`Remote command exited with code ${code}\n${output}`.slice(0, 4000));
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
output: output || 'Remote command completed with code 0',
|
|
157
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
158
|
+
costUsd: 0,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9). */
|
|
163
|
+
async _runExternal(task, runtime, signal) {
|
|
164
|
+
if (runtime === 'ssh' && task.sshProfileId) {
|
|
165
|
+
const viaService = await this._runViaRemoteWorkspace(task, signal);
|
|
166
|
+
if (viaService) return viaService;
|
|
167
|
+
if (!task.sshTarget) {
|
|
168
|
+
throw new Error('SSH task needs either a working dsh-remote-workspace profile (sshProfileId) or an sshTarget');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const cwd = this.resolveCwd(task);
|
|
172
|
+
const invocation = buildInvocation({ ...task, type: runtime }, cwd);
|
|
173
|
+
if (invocation.http) return this._runHttp(task, invocation.http, signal);
|
|
174
|
+
|
|
175
|
+
const { exec, execFile } = await import('node:child_process');
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const done = (err, stdout, stderr) => {
|
|
178
|
+
if (err) {
|
|
179
|
+
if (signal && signal.aborted) {
|
|
180
|
+
return reject(signal.reason || new Error('Command aborted'));
|
|
66
181
|
}
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
182
|
+
const detail = String(stderr || '').trim();
|
|
183
|
+
return reject(new Error(detail ? `${err.message}\n${detail}` : err.message));
|
|
184
|
+
}
|
|
185
|
+
const out = (stdout || stderr || '').trim();
|
|
186
|
+
resolve({
|
|
187
|
+
output: out || `${runtime} task completed with code 0`,
|
|
188
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
189
|
+
costUsd: 0,
|
|
73
190
|
});
|
|
74
|
-
}
|
|
191
|
+
};
|
|
192
|
+
const options = { cwd, signal, maxBuffer: 10 * 1024 * 1024, env: invocation.env };
|
|
193
|
+
if (invocation.file) {
|
|
194
|
+
execFile(invocation.file, invocation.args || [], options, done);
|
|
195
|
+
} else {
|
|
196
|
+
const shellPath = process.platform === 'win32' ? undefined : (process.env.SHELL || '/bin/bash');
|
|
197
|
+
exec(invocation.shellCommand, { ...options, shell: shellPath }, done);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async _run(task, signal) {
|
|
203
|
+
console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
|
|
204
|
+
|
|
205
|
+
// 1. Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9)
|
|
206
|
+
const runtime = normalizeTaskType(task.type);
|
|
207
|
+
if (EXTERNAL_RUNTIMES.includes(runtime) || task.mode === 'no-llm') {
|
|
208
|
+
return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal);
|
|
75
209
|
}
|
|
76
210
|
|
|
77
211
|
// 2. If ctx.agents is available, run through agent session (LLM)
|
|
@@ -121,7 +255,26 @@ export class SessionRunner {
|
|
|
121
255
|
]);
|
|
122
256
|
};
|
|
123
257
|
|
|
124
|
-
const
|
|
258
|
+
const baseCwd = this.resolveCwd(task);
|
|
259
|
+
// #29: code-modifying tasks may run in an isolated git worktree.
|
|
260
|
+
let cwd = baseCwd;
|
|
261
|
+
let taskWorktree = null;
|
|
262
|
+
if (task.worktree) {
|
|
263
|
+
try {
|
|
264
|
+
taskWorktree = await this.createTaskWorktree(baseCwd, task);
|
|
265
|
+
cwd = taskWorktree;
|
|
266
|
+
} catch (wtErr) {
|
|
267
|
+
console.warn('[dsh-cron] task worktree not created, running in the workspace:', wtErr.message);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// #6/#10: skill- and workflow-driven tasks are agent-mediated with a
|
|
271
|
+
// shaped prompt naming the skill/workflow.
|
|
272
|
+
let agentPrompt = String(task.prompt || '');
|
|
273
|
+
if (runtime === 'skill' && task.skillName) {
|
|
274
|
+
agentPrompt = `Use the DSH skill "${task.skillName}" for this task:\n${agentPrompt}`;
|
|
275
|
+
} else if (runtime === 'workflow' && task.workflowName) {
|
|
276
|
+
agentPrompt = `Execute this through the DSH workflow "${task.workflowName}" step by step:\n${agentPrompt}`;
|
|
277
|
+
}
|
|
125
278
|
let handle = null;
|
|
126
279
|
try {
|
|
127
280
|
handle = await this.ctx.agents.create({
|
|
@@ -144,7 +297,7 @@ export class SessionRunner {
|
|
|
144
297
|
|
|
145
298
|
await raceAbort(handle.agent.whenIdle());
|
|
146
299
|
handle.agent.followup(createUserMessage({
|
|
147
|
-
content: [{ type: 'text', text:
|
|
300
|
+
content: [{ type: 'text', text: agentPrompt }],
|
|
148
301
|
source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
|
|
149
302
|
}));
|
|
150
303
|
|
|
@@ -199,6 +352,10 @@ export class SessionRunner {
|
|
|
199
352
|
}
|
|
200
353
|
} catch {}
|
|
201
354
|
}
|
|
355
|
+
// #29: dispose the isolated worktree (kept when keepWorktree is set).
|
|
356
|
+
if (taskWorktree && !task.keepWorktree) {
|
|
357
|
+
await this.removeTaskWorktree(baseCwd, taskWorktree);
|
|
358
|
+
}
|
|
202
359
|
}
|
|
203
360
|
} catch (err) {
|
|
204
361
|
console.error(`[dsh-cron] agent execution failed:`, err.message);
|
package/lib/runtimes.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime invocation builders for non-LLM task types (#4, #5, #7, #8, #9, #38).
|
|
3
|
+
*
|
|
4
|
+
* Every builder is a pure function returning the process invocation
|
|
5
|
+
* ({ file, args, env }) or the HTTP request description, so the whole matrix
|
|
6
|
+
* is unit-testable without spawning anything.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
export const EXTERNAL_RUNTIMES = ['script', 'node', 'python', 'http', 'ssh', 'docker'];
|
|
13
|
+
export const AGENT_RUNTIMES = ['llm', 'skill', 'workflow'];
|
|
14
|
+
export const TASK_TYPES = [...AGENT_RUNTIMES, ...EXTERNAL_RUNTIMES];
|
|
15
|
+
|
|
16
|
+
/** Types that execute code and therefore need the HTTP confirm header. */
|
|
17
|
+
export const CODE_EXECUTING_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
|
|
18
|
+
|
|
19
|
+
export function normalizeTaskType(type) {
|
|
20
|
+
return TASK_TYPES.includes(type) ? type : 'llm';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Split a leading script path from trailing CLI arguments.
|
|
25
|
+
* Quoted segments are preserved as single arguments.
|
|
26
|
+
*/
|
|
27
|
+
export function splitCommandLine(text) {
|
|
28
|
+
const trimmed = String(text || '').trim();
|
|
29
|
+
if (!trimmed) return { path: '', args: [] };
|
|
30
|
+
const tokens = trimmed.match(/"[^"]*"|'[^']*'|\S+/g) || [];
|
|
31
|
+
const clean = tokens.map((t) => t.replace(/^["']|["']$/g, ''));
|
|
32
|
+
return { path: clean[0] || '', args: clean.slice(1) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function looksLikeScriptFile(token, extensions) {
|
|
36
|
+
if (!token) return false;
|
|
37
|
+
const ext = path.extname(token).toLowerCase();
|
|
38
|
+
if (extensions.includes(ext)) return true;
|
|
39
|
+
try {
|
|
40
|
+
return fs.existsSync(token) && fs.statSync(token).isFile();
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** #5: resolve a Node.js invocation (inline code or a .js/.mjs/.cjs file). */
|
|
47
|
+
export function buildNodeInvocation(task, cwd) {
|
|
48
|
+
const { path: filePath, args } = splitCommandLine(task.prompt);
|
|
49
|
+
if (looksLikeScriptFile(filePath, ['.js', '.mjs', '.cjs'])) {
|
|
50
|
+
return { file: process.execPath, args: [filePath, ...args] };
|
|
51
|
+
}
|
|
52
|
+
const code = String(task.prompt || '');
|
|
53
|
+
const isEsm = /\bimport\s|\bexport\s/.test(code);
|
|
54
|
+
const nodeArgs = isEsm ? ['--input-type=module', '-e', code] : ['-e', code];
|
|
55
|
+
return { file: task.nodePath || process.execPath, args: nodeArgs };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Find the Python interpreter, preferring a task virtualenv, then the workspace one. */
|
|
59
|
+
export function detectPython(task, cwd) {
|
|
60
|
+
if (task.pythonPath) return task.pythonPath;
|
|
61
|
+
const candidates = [];
|
|
62
|
+
if (process.env.VIRTUAL_ENV) {
|
|
63
|
+
candidates.push(path.join(process.env.VIRTUAL_ENV, 'bin', 'python'));
|
|
64
|
+
candidates.push(path.join(process.env.VIRTUAL_ENV, 'Scripts', 'python.exe'));
|
|
65
|
+
}
|
|
66
|
+
const base = cwd || process.cwd();
|
|
67
|
+
candidates.push(path.join(base, '.venv', 'bin', 'python'));
|
|
68
|
+
candidates.push(path.join(base, '.venv', 'Scripts', 'python.exe'));
|
|
69
|
+
candidates.push(path.join(base, 'venv', 'bin', 'python'));
|
|
70
|
+
for (const candidate of candidates) {
|
|
71
|
+
try {
|
|
72
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
73
|
+
} catch {}
|
|
74
|
+
}
|
|
75
|
+
return process.platform === 'win32' ? 'python' : 'python3';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** #4: resolve a Python invocation (inline code or a .py file). */
|
|
79
|
+
export function buildPythonInvocation(task, cwd) {
|
|
80
|
+
const interpreter = detectPython(task, cwd);
|
|
81
|
+
const { path: filePath, args } = splitCommandLine(task.prompt);
|
|
82
|
+
if (looksLikeScriptFile(filePath, ['.py'])) {
|
|
83
|
+
return { file: interpreter, args: [filePath, ...args] };
|
|
84
|
+
}
|
|
85
|
+
return { file: interpreter, args: ['-c', String(task.prompt || '')] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** #7: build the HTTP trigger request description. */
|
|
89
|
+
export function buildHttpRequest(task) {
|
|
90
|
+
const raw = String(task.httpUrl || task.prompt || '').trim();
|
|
91
|
+
if (!raw) throw new Error('HTTP task requires a URL (httpUrl or prompt)');
|
|
92
|
+
let url;
|
|
93
|
+
try {
|
|
94
|
+
url = new URL(raw);
|
|
95
|
+
} catch {
|
|
96
|
+
throw new Error(`Invalid HTTP URL: ${raw}`);
|
|
97
|
+
}
|
|
98
|
+
let headers = {};
|
|
99
|
+
if (task.httpHeaders) {
|
|
100
|
+
if (typeof task.httpHeaders === 'object') {
|
|
101
|
+
headers = { ...task.httpHeaders };
|
|
102
|
+
} else {
|
|
103
|
+
try {
|
|
104
|
+
headers = JSON.parse(String(task.httpHeaders));
|
|
105
|
+
} catch {
|
|
106
|
+
throw new Error('httpHeaders must be a JSON object');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const method = String(task.httpMethod || 'GET').toUpperCase();
|
|
111
|
+
const body = task.httpBody !== undefined && task.httpBody !== '' ? String(task.httpBody) : undefined;
|
|
112
|
+
return { url: url.toString(), method, headers, body };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** #9: build a remote SSH invocation. */
|
|
116
|
+
export function buildSshInvocation(task) {
|
|
117
|
+
const target = String(task.sshTarget || '').trim();
|
|
118
|
+
if (!target) throw new Error('SSH task requires sshTarget (user@host)');
|
|
119
|
+
const args = [];
|
|
120
|
+
if (task.sshPort) args.push('-p', String(Number(task.sshPort)));
|
|
121
|
+
if (task.sshKeyPath) args.push('-i', String(task.sshKeyPath));
|
|
122
|
+
args.push(target, String(task.prompt || 'true'));
|
|
123
|
+
return { file: 'ssh', args };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** #8: build a Docker run invocation. */
|
|
127
|
+
export function buildDockerInvocation(task, env) {
|
|
128
|
+
const image = String(task.dockerImage || '').trim();
|
|
129
|
+
if (!image) throw new Error('Docker task requires dockerImage');
|
|
130
|
+
const args = ['run', '--rm'];
|
|
131
|
+
for (const [key, value] of Object.entries(env || {})) {
|
|
132
|
+
if (key === 'PATH' || key === 'HOME') continue; // container owns those
|
|
133
|
+
args.push('-e', `${key}=${value}`);
|
|
134
|
+
}
|
|
135
|
+
if (task.cwd) args.push('-v', `${task.cwd}:/workspace`, '-w', '/workspace');
|
|
136
|
+
args.push(image, 'sh', '-lc', String(task.prompt || 'true'));
|
|
137
|
+
return { file: 'docker', args };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** #38: merge task env vars over the base environment. */
|
|
141
|
+
export function buildEnv(task, baseEnv) {
|
|
142
|
+
const env = { ...(baseEnv || process.env) };
|
|
143
|
+
if (task && task.env && typeof task.env === 'object') {
|
|
144
|
+
for (const [key, value] of Object.entries(task.env)) {
|
|
145
|
+
if (value === null || value === undefined) delete env[key];
|
|
146
|
+
else env[key] = String(value);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return env;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Dispatch an external (non-LLM) runtime to its invocation.
|
|
154
|
+
* Returns { file, args } plus an optional `http` descriptor for the fetch path.
|
|
155
|
+
*/
|
|
156
|
+
export function buildInvocation(task, cwd, baseEnv) {
|
|
157
|
+
const type = normalizeTaskType(task.type);
|
|
158
|
+
const env = buildEnv(task, baseEnv);
|
|
159
|
+
switch (type) {
|
|
160
|
+
case 'node':
|
|
161
|
+
return { type, env, ...buildNodeInvocation(task, cwd) };
|
|
162
|
+
case 'python':
|
|
163
|
+
return { type, env, ...buildPythonInvocation(task, cwd) };
|
|
164
|
+
case 'http':
|
|
165
|
+
return { type, env, http: buildHttpRequest(task) };
|
|
166
|
+
case 'ssh':
|
|
167
|
+
return { type, env, ...buildSshInvocation(task) };
|
|
168
|
+
case 'docker':
|
|
169
|
+
return { type, env, ...buildDockerInvocation(task, env) };
|
|
170
|
+
case 'script':
|
|
171
|
+
default:
|
|
172
|
+
return { type: 'script', env, file: null, args: null, shellCommand: String(task.prompt || '') };
|
|
173
|
+
}
|
|
174
|
+
}
|
package/lib/scheduler.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { Cron } from 'croner';
|
|
2
|
-
import { shouldNotifyTask, formatTaskTelegramMessage, sendTelegramMessage } from './telegram.js';
|
|
3
|
-
import { createKanbanCard, shouldCreateKanbanCard } from './integrations.js';
|
|
4
2
|
|
|
5
3
|
function pad(v) {
|
|
6
4
|
return String(v).padStart(2, '0');
|
|
@@ -142,6 +140,8 @@ export class TaskScheduler {
|
|
|
142
140
|
this.executeFn = executeFn;
|
|
143
141
|
this.maxConcurrent = Number(options.maxConcurrent) > 0 ? Number(options.maxConcurrent) : 0; // 0 = unlimited (#52)
|
|
144
142
|
this.defaultTimezone = options.defaultTimezone || ''; // #13
|
|
143
|
+
this.resolveSecrets = typeof options.resolveSecrets === 'function' ? options.resolveSecrets : null; // #51
|
|
144
|
+
this.deliver = typeof options.deliver === 'function' ? options.deliver : null; // #26 channel router
|
|
145
145
|
this.jobs = new Map(); // taskId -> Cron instance
|
|
146
146
|
this.timers = new Map(); // taskId -> setTimeout handle (for one-shot tasks)
|
|
147
147
|
this.retryTimers = new Map(); // taskId -> setTimeout handle (for #18 retries)
|
|
@@ -266,9 +266,11 @@ export class TaskScheduler {
|
|
|
266
266
|
this.store.set(task);
|
|
267
267
|
|
|
268
268
|
// If target is in the past, or when delay triggers:
|
|
269
|
-
const timer = setTimeout(
|
|
269
|
+
const timer = setTimeout(() => {
|
|
270
270
|
this.timers.delete(task.id);
|
|
271
|
-
|
|
271
|
+
this.runTask(task.id).catch((err) => {
|
|
272
|
+
console.error(`[dsh-cron] one-shot run of task ${task.id} failed:`, (err && err.message) || err);
|
|
273
|
+
});
|
|
272
274
|
}, delay);
|
|
273
275
|
|
|
274
276
|
this.timers.set(task.id, timer);
|
|
@@ -276,7 +278,11 @@ export class TaskScheduler {
|
|
|
276
278
|
}
|
|
277
279
|
|
|
278
280
|
// Handle Recurring Cron job
|
|
279
|
-
const job = new Cron(parsed.cronPattern, {
|
|
281
|
+
const job = new Cron(parsed.cronPattern, {
|
|
282
|
+
protect: true,
|
|
283
|
+
catch: (err) => console.error(`[dsh-cron] scheduled run of "${task.title}" (${task.id}) failed:`, (err && err.message) || err),
|
|
284
|
+
...(timezone ? { timezone } : {}),
|
|
285
|
+
}, async () => {
|
|
280
286
|
await this.runTask(task.id);
|
|
281
287
|
});
|
|
282
288
|
|
|
@@ -388,7 +394,11 @@ export class TaskScheduler {
|
|
|
388
394
|
costUsd,
|
|
389
395
|
sessionId,
|
|
390
396
|
};
|
|
391
|
-
|
|
397
|
+
try {
|
|
398
|
+
this.store.recordRun(taskId, runInfo);
|
|
399
|
+
} catch (recordErr) {
|
|
400
|
+
console.error(`[dsh-cron] failed to record the run of task ${taskId}:`, recordErr.message);
|
|
401
|
+
}
|
|
392
402
|
|
|
393
403
|
// Clean up running map
|
|
394
404
|
const finishedRun = this.running.get(taskId);
|
|
@@ -398,58 +408,48 @@ export class TaskScheduler {
|
|
|
398
408
|
|
|
399
409
|
const queuedCount = finishedRun?.queueCount || 0;
|
|
400
410
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const botToken = settings.botToken;
|
|
406
|
-
const chatId = settings.chatId;
|
|
407
|
-
if (botToken && chatId) {
|
|
408
|
-
const text = formatTaskTelegramMessage(task, runInfo);
|
|
409
|
-
sendTelegramMessage({ botToken, chatId, text }).catch((err) => {
|
|
410
|
-
console.error(`[dsh-cron] telegram delivery failed for task "${task.title}":`, err.message);
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
} catch (notifyErr) {
|
|
415
|
-
console.error('[dsh-cron] notification error:', notifyErr.message);
|
|
416
|
-
}
|
|
411
|
+
await this.deliverNotifications(task, runInfo);
|
|
412
|
+
this.finishRun(task, taskId, status, queuedCount);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
417
415
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
createKanbanCard({
|
|
434
|
-
title: cardTitle,
|
|
435
|
-
body: cardBody,
|
|
436
|
-
board: 'main',
|
|
437
|
-
column: isErr ? 'backlog' : 'done',
|
|
438
|
-
labels: isErr ? ['cron', 'bug', 'alert'] : ['cron', 'auto'],
|
|
439
|
-
kanbanBaseUrl: settings.kanbanBaseUrl || 'http://127.0.0.1:3000'
|
|
440
|
-
}).then((res) => {
|
|
441
|
-
if (res.success) {
|
|
442
|
-
console.log(`[dsh-cron] created kanban card for task "${task.title}"`);
|
|
443
|
-
}
|
|
444
|
-
}).catch((kErr) => {
|
|
445
|
-
console.error('[dsh-cron] kanban card error:', kErr.message);
|
|
446
|
-
});
|
|
416
|
+
/**
|
|
417
|
+
* Deliver a finished run to every configured channel (#26).
|
|
418
|
+
* Never throws: a broken or slow channel is reported, not propagated, so the
|
|
419
|
+
* cron callback (and therefore the next tick) is never blocked by delivery.
|
|
420
|
+
*/
|
|
421
|
+
async deliverNotifications(task, runInfo) {
|
|
422
|
+
try {
|
|
423
|
+
let settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
|
|
424
|
+
// #51: resolve delivery credentials through the credentials service
|
|
425
|
+
// (credential reference → legacy token → gateway file → env).
|
|
426
|
+
if (this.resolveSecrets) {
|
|
427
|
+
try {
|
|
428
|
+
settings = { ...settings, ...(await this.resolveSecrets(settings)) };
|
|
429
|
+
} catch (secErr) {
|
|
430
|
+
console.warn('[dsh-cron] credential resolution failed:', secErr.message);
|
|
447
431
|
}
|
|
448
|
-
} catch (kanbanErr) {
|
|
449
|
-
console.error('[dsh-cron] kanban error:', kanbanErr.message);
|
|
450
432
|
}
|
|
433
|
+
if (typeof this.deliver !== 'function') return;
|
|
434
|
+
const result = await this.deliver(task, runInfo, settings);
|
|
435
|
+
if (result && Array.isArray(result.delivered) && result.delivered.length) {
|
|
436
|
+
console.log(`[dsh-cron] delivered "${task.title}" to: ${result.delivered.map((d) => d.channel).join(', ')}`);
|
|
437
|
+
}
|
|
438
|
+
if (result && Array.isArray(result.failures) && result.failures.length) {
|
|
439
|
+
console.warn(`[dsh-cron] delivery failures for "${task.title}": ${result.failures.map((f) => `${f.channel}: ${f.error}`).join('; ')}`);
|
|
440
|
+
}
|
|
441
|
+
} catch (notifyErr) {
|
|
442
|
+
console.error('[dsh-cron] notification error:', notifyErr.message);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
451
445
|
|
|
452
|
-
|
|
446
|
+
/**
|
|
447
|
+
* Post-run bookkeeping: one-shot completion, next-run timestamp, retry
|
|
448
|
+
* backoff and draining a queued run. Each part is guarded so a store error
|
|
449
|
+
* cannot reject the cron callback.
|
|
450
|
+
*/
|
|
451
|
+
finishRun(task, taskId, status, queuedCount) {
|
|
452
|
+
try {
|
|
453
453
|
if (task.oneShot) {
|
|
454
454
|
task.status = 'completed';
|
|
455
455
|
task.nextRunAt = null;
|
|
@@ -462,8 +462,12 @@ export class TaskScheduler {
|
|
|
462
462
|
this.store.set(task);
|
|
463
463
|
}
|
|
464
464
|
}
|
|
465
|
+
} catch (err) {
|
|
466
|
+
console.error('[dsh-cron] failed to persist the next run of task', taskId + ':', err.message);
|
|
467
|
+
}
|
|
465
468
|
|
|
466
|
-
|
|
469
|
+
try {
|
|
470
|
+
// Retry with exponential backoff on failure (#18)
|
|
467
471
|
const failed = status === 'error' || status === 'timeout';
|
|
468
472
|
const maxRetries = Number(task.maxRetries) > 0 ? Number(task.maxRetries) : 0;
|
|
469
473
|
if (failed && maxRetries > 0 && (task.attempts || 0) < maxRetries && task.status !== 'paused') {
|
|
@@ -481,12 +485,14 @@ export class TaskScheduler {
|
|
|
481
485
|
task.attempts = 0;
|
|
482
486
|
this.store.set(task);
|
|
483
487
|
}
|
|
488
|
+
} catch (err) {
|
|
489
|
+
console.error('[dsh-cron] retry bookkeeping failed for task', taskId + ':', err.message);
|
|
490
|
+
}
|
|
484
491
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
492
|
+
if (queuedCount > 0) {
|
|
493
|
+
setImmediate(() => {
|
|
494
|
+
this.runTask(taskId);
|
|
495
|
+
});
|
|
490
496
|
}
|
|
491
497
|
}
|
|
492
498
|
|