@goodandready/dsh-cron 0.2.9 → 0.2.11
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 -2
- package/README.ru.md +481 -0
- package/README.zh.md +481 -0
- package/docs/README.ru.md +34 -1
- package/docs/README.zh.md +34 -1
- package/lib/api.js +224 -2
- package/lib/channels.js +3 -2
- package/lib/client.js +594 -3
- package/lib/index.js +21 -2
- package/lib/llm-actions.js +141 -0
- package/lib/metrics.js +24 -1
- package/lib/runner.js +24 -11
- package/lib/scheduler.js +302 -13
- package/lib/store.js +90 -0
- package/lib/task-transfer.js +10 -0
- package/lib/telegram.js +43 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { TaskScheduler, parseScheduleExpression } from './scheduler.js';
|
|
|
3
3
|
import { SessionRunner } from './runner.js';
|
|
4
4
|
import { getModelsHandler } from './models-handler.js';
|
|
5
5
|
import { chatStartHandler } from './chat-start.js';
|
|
6
|
-
import { sendTelegramMessage } from './telegram.js';
|
|
6
|
+
import { sendTelegramMessage, createTelegramTaskKeyboard } from './telegram.js';
|
|
7
7
|
import { createKanbanCard } from './integrations.js';
|
|
8
8
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
@@ -393,6 +393,24 @@ export function apply(ctx, config) {
|
|
|
393
393
|
handler: createCronApiHandler(store, scheduler, null)
|
|
394
394
|
}), 'dsh-cron: /action/:id/:action');
|
|
395
395
|
|
|
396
|
+
ctx.effect(() => ctx.webServer.register({
|
|
397
|
+
kind: 'prefix',
|
|
398
|
+
path: '/dsh-cron/telegram',
|
|
399
|
+
handler: createCronApiHandler(store, scheduler, null)
|
|
400
|
+
}), 'dsh-cron: /telegram[...]');
|
|
401
|
+
|
|
402
|
+
ctx.effect(() => ctx.webServer.register({
|
|
403
|
+
kind: 'prefix',
|
|
404
|
+
path: '/dsh-cron/heartbeat-ping',
|
|
405
|
+
handler: createCronApiHandler(store, scheduler, null)
|
|
406
|
+
}), 'dsh-cron: /heartbeat-ping[...]');
|
|
407
|
+
|
|
408
|
+
ctx.effect(() => ctx.webServer.register({
|
|
409
|
+
kind: 'prefix',
|
|
410
|
+
path: '/dsh-cron/schedule',
|
|
411
|
+
handler: createCronApiHandler(store, scheduler, null)
|
|
412
|
+
}), 'dsh-cron: /schedule[...]');
|
|
413
|
+
|
|
396
414
|
// GET /dsh-cron/metrics (#53) — Prometheus text exposition
|
|
397
415
|
ctx.effect(() => ctx.webServer.register({
|
|
398
416
|
kind: 'exact',
|
|
@@ -527,7 +545,8 @@ export function apply(ctx, config) {
|
|
|
527
545
|
}
|
|
528
546
|
|
|
529
547
|
const text = `🔔 *dsh-cron test notification*\n\nConnection between DSH Cron and Telegram is working! Checked at: \`${new Date().toISOString()}\``;
|
|
530
|
-
|
|
548
|
+
const replyMarkup = createTelegramTaskKeyboard(task, runInfo);
|
|
549
|
+
await sendTelegramMessage({ botToken, chatId, text, replyMarkup });
|
|
531
550
|
sendJson(res, 200, { ok: true, message: 'Test message sent successfully' });
|
|
532
551
|
} catch (err) {
|
|
533
552
|
sendJson(res, 500, { ok: false, error: err.message });
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract structured action directives from LLM output text (#137).
|
|
5
|
+
* Supports JSON code fences ```json ... ``` and inline { "dsh_action": ... } blocks.
|
|
6
|
+
*/
|
|
7
|
+
export function parseLlmActionDirectives(text) {
|
|
8
|
+
if (!text || typeof text !== 'string') return [];
|
|
9
|
+
const directives = [];
|
|
10
|
+
|
|
11
|
+
// 1. Check for ```json ... ``` or ``` ... ``` blocks
|
|
12
|
+
const blockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/gi;
|
|
13
|
+
let match;
|
|
14
|
+
while ((match = blockRegex.exec(text)) !== null) {
|
|
15
|
+
const raw = match[1].trim();
|
|
16
|
+
if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(raw);
|
|
19
|
+
collectDirectives(parsed, directives);
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 2. Check for inline JSON object with dsh_action or dsh_actions if nothing found in fences
|
|
25
|
+
if (directives.length === 0) {
|
|
26
|
+
const inlineMatch = /\{(?:[^{}]|(\{(?:[^{}]|\{[^{}]*\})*\}))*"dsh_action[s]?"(?:[^{}]|(\{(?:[^{}]|\{[^{}]*\})*\}))*\}/gi;
|
|
27
|
+
let im;
|
|
28
|
+
while ((im = inlineMatch.exec(text)) !== null) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(im[0]);
|
|
31
|
+
collectDirectives(parsed, directives);
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return directives;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function collectDirectives(obj, list) {
|
|
40
|
+
if (!obj || typeof obj !== 'object') return;
|
|
41
|
+
if (Array.isArray(obj)) {
|
|
42
|
+
for (const item of obj) collectDirectives(item, list);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (Array.isArray(obj.dsh_actions)) {
|
|
46
|
+
for (const item of obj.dsh_actions) collectDirectives(item, list);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(obj.actions)) {
|
|
50
|
+
for (const item of obj.actions) collectDirectives(item, list);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const type = obj.dsh_action || obj.action || obj.type;
|
|
54
|
+
if (typeof type === 'string' && type.trim()) {
|
|
55
|
+
list.push({
|
|
56
|
+
type: type.trim(),
|
|
57
|
+
payload: obj.payload || {},
|
|
58
|
+
...obj,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Execute parsed LLM action directives if enabled in plugin settings (#137).
|
|
65
|
+
*/
|
|
66
|
+
export async function executeLlmActionDirectives({ directives, task, runInfo, scheduler, store, settings = {}, fetchFn = globalThis.fetch }) {
|
|
67
|
+
if (!Array.isArray(directives) || directives.length === 0) return [];
|
|
68
|
+
if (!settings.llmActionsEnabled) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const results = [];
|
|
73
|
+
for (const dir of directives) {
|
|
74
|
+
const type = dir.type || dir.action;
|
|
75
|
+
try {
|
|
76
|
+
if (type === 'trigger_task' || type === 'run_task') {
|
|
77
|
+
const targetId = dir.taskId || dir.payload?.taskId;
|
|
78
|
+
if (targetId && typeof scheduler.runNow === 'function') {
|
|
79
|
+
await scheduler.runNow(targetId, {
|
|
80
|
+
prevOutput: runInfo?.output || '',
|
|
81
|
+
prevTaskId: task?.id || '',
|
|
82
|
+
});
|
|
83
|
+
results.push({ type, targetId, status: 'triggered' });
|
|
84
|
+
}
|
|
85
|
+
} else if (type === 'notify') {
|
|
86
|
+
const msg = dir.message || dir.text || dir.payload?.message;
|
|
87
|
+
const botToken = settings.botToken;
|
|
88
|
+
const chatId = settings.chatId;
|
|
89
|
+
if (msg && botToken && chatId) {
|
|
90
|
+
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
|
91
|
+
await fetchFn(url, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: { 'Content-Type': 'application/json' },
|
|
94
|
+
body: JSON.stringify({ chat_id: chatId, text: String(msg) }),
|
|
95
|
+
});
|
|
96
|
+
results.push({ type, status: 'sent' });
|
|
97
|
+
}
|
|
98
|
+
} else if (type === 'create_issue') {
|
|
99
|
+
const title = dir.title || dir.payload?.title || `Issue from task ${task?.title || 'Cron'}`;
|
|
100
|
+
const body = dir.body || dir.payload?.body || String(runInfo?.output || '');
|
|
101
|
+
const giteaRes = await tryCreateGiteaIssue({ title, body, fetchFn });
|
|
102
|
+
results.push({ type, status: giteaRes ? 'created' : 'skipped', details: giteaRes });
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.warn(`[dsh-cron] action directive ${type} execution failed:`, err.message);
|
|
106
|
+
results.push({ type, status: 'error', error: err.message });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return results;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Best-effort helper to post an issue to local Gitea if credentials are present.
|
|
115
|
+
*/
|
|
116
|
+
async function tryCreateGiteaIssue({ title, body, repo = 'goodandready/dsh-cron', fetchFn }) {
|
|
117
|
+
try {
|
|
118
|
+
let token = process.env.GITEA_TOKEN;
|
|
119
|
+
const credPath = '/mnt/external/Project/DEV/.gitea-agent-credentials.json';
|
|
120
|
+
if (!token && fs.existsSync(credPath)) {
|
|
121
|
+
const data = JSON.parse(fs.readFileSync(credPath, 'utf-8'));
|
|
122
|
+
token = data?.agents?.antigravity?.token || data?.token;
|
|
123
|
+
}
|
|
124
|
+
if (!token) return null;
|
|
125
|
+
|
|
126
|
+
const url = `http://127.0.0.1:3005/api/v1/repos/${repo}/issues`;
|
|
127
|
+
const res = await fetchFn(url, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: {
|
|
130
|
+
'Authorization': `token ${token}`,
|
|
131
|
+
'Content-Type': 'application/json',
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify({ title, body }),
|
|
134
|
+
});
|
|
135
|
+
if (res.ok) {
|
|
136
|
+
const json = await res.json().catch(() => ({}));
|
|
137
|
+
return { number: json.number, url: json.html_url };
|
|
138
|
+
}
|
|
139
|
+
} catch {}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
package/lib/metrics.js
CHANGED
|
@@ -36,7 +36,7 @@ function sample(name, labels, value) {
|
|
|
36
36
|
* Render the current scheduler state as Prometheus text exposition.
|
|
37
37
|
* Pure function over plain data so it is testable without a store.
|
|
38
38
|
*/
|
|
39
|
-
export function renderMetrics({ tasks = [], stats = {}, runCounters = {} } = {}) {
|
|
39
|
+
export function renderMetrics({ tasks = [], stats = {}, runCounters = {}, scheduler } = {}) {
|
|
40
40
|
const lines = [];
|
|
41
41
|
const byStatus = {};
|
|
42
42
|
for (const task of tasks) {
|
|
@@ -72,6 +72,28 @@ export function renderMetrics({ tasks = [], stats = {}, runCounters = {} } = {})
|
|
|
72
72
|
lines.push('# TYPE dsh_cron_run_records gauge');
|
|
73
73
|
lines.push('dsh_cron_run_records ' + (Number(stats.totalRuns) || 0));
|
|
74
74
|
|
|
75
|
+
if (scheduler && typeof scheduler.runningCount === 'function') {
|
|
76
|
+
lines.push('# HELP dsh_cron_concurrent_running Number of currently executing tasks.');
|
|
77
|
+
lines.push('# TYPE dsh_cron_concurrent_running gauge');
|
|
78
|
+
lines.push('dsh_cron_concurrent_running ' + (Number(scheduler.runningCount()) || 0));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
lines.push('# HELP dsh_cron_task_tokens_total Cumulative tokens consumed by task.');
|
|
82
|
+
lines.push('# TYPE dsh_cron_task_tokens_total counter');
|
|
83
|
+
for (const task of tasks) {
|
|
84
|
+
if (Number(task.totalTokens) > 0) {
|
|
85
|
+
lines.push(sample('dsh_cron_task_tokens_total', { task: task.id }, task.totalTokens));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
lines.push('# HELP dsh_cron_task_cost_usd_total Estimated cumulative LLM cost in USD.');
|
|
90
|
+
lines.push('# TYPE dsh_cron_task_cost_usd_total counter');
|
|
91
|
+
for (const task of tasks) {
|
|
92
|
+
if (Number(task.totalCostUsd) > 0) {
|
|
93
|
+
lines.push(sample('dsh_cron_task_cost_usd_total', { task: task.id }, task.totalCostUsd));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
75
97
|
return lines.join(NL) + NL;
|
|
76
98
|
}
|
|
77
99
|
|
|
@@ -90,6 +112,7 @@ export function createMetricsHandler({ store, scheduler }) {
|
|
|
90
112
|
tasks: store.list({ status: 'all' }),
|
|
91
113
|
stats: store.getAggregatedStats(),
|
|
92
114
|
runCounters: typeof scheduler.getRunCounters === 'function' ? scheduler.getRunCounters() : {},
|
|
115
|
+
scheduler,
|
|
93
116
|
});
|
|
94
117
|
res.writeHead(200, { 'Content-Type': METRICS_CONTENT_TYPE, 'Cache-Control': 'no-store' });
|
|
95
118
|
res.end(req.method === 'HEAD' ? undefined : body);
|
package/lib/runner.js
CHANGED
|
@@ -54,7 +54,7 @@ export class SessionRunner {
|
|
|
54
54
|
}, timeoutMs);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
const executionPromise = this._run(task, controller.signal);
|
|
57
|
+
const executionPromise = this._run(task, controller.signal, options);
|
|
58
58
|
|
|
59
59
|
try {
|
|
60
60
|
return await Promise.race([executionPromise, timeoutPromise]);
|
|
@@ -195,7 +195,7 @@ export class SessionRunner {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
/** Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9). */
|
|
198
|
-
async _runExternal(task, runtime, signal) {
|
|
198
|
+
async _runExternal(task, runtime, signal, execOptions = {}) {
|
|
199
199
|
if (runtime === 'ssh' && task.sshProfileId) {
|
|
200
200
|
const viaService = await this._runViaRemoteWorkspace(task, signal);
|
|
201
201
|
if (viaService) return viaService;
|
|
@@ -239,9 +239,13 @@ export class SessionRunner {
|
|
|
239
239
|
signal.addEventListener('abort', abortHandler, { once: true });
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
const runEnv = { ...invocation.env };
|
|
243
|
+
if (execOptions?.prevOutput !== undefined) {
|
|
244
|
+
runEnv.DSH_PREV_OUTPUT = String(execOptions.prevOutput);
|
|
245
|
+
}
|
|
242
246
|
const options = {
|
|
243
247
|
cwd,
|
|
244
|
-
env:
|
|
248
|
+
env: runEnv,
|
|
245
249
|
detached: isPosix,
|
|
246
250
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
247
251
|
};
|
|
@@ -293,27 +297,27 @@ export class SessionRunner {
|
|
|
293
297
|
});
|
|
294
298
|
}
|
|
295
299
|
|
|
296
|
-
async _run(task, signal) {
|
|
300
|
+
async _run(task, signal, options = {}) {
|
|
297
301
|
console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
|
|
298
302
|
|
|
299
303
|
// 1. Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9)
|
|
300
304
|
const runtime = normalizeTaskType(task.type);
|
|
301
305
|
if (EXTERNAL_RUNTIMES.includes(runtime) || task.mode === 'no-llm') {
|
|
302
|
-
return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal);
|
|
306
|
+
return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal, options);
|
|
303
307
|
}
|
|
304
308
|
|
|
305
309
|
// 2. Agent session, when the harness exposes one
|
|
306
310
|
if (this.ctx && this.ctx.agents && typeof this.ctx.agents.create === 'function') {
|
|
307
|
-
return this._runAgentSession(task, runtime, signal);
|
|
311
|
+
return this._runAgentSession(task, runtime, signal, options);
|
|
308
312
|
}
|
|
309
313
|
|
|
310
314
|
// 3. Legacy session service
|
|
311
315
|
if (this.ctx && this.ctx.session && typeof this.ctx.session.create === 'function') {
|
|
312
|
-
return this._runLegacySession(task);
|
|
316
|
+
return this._runLegacySession(task, options);
|
|
313
317
|
}
|
|
314
318
|
|
|
315
319
|
// 4. Nothing to run through: report the prompt as triggered
|
|
316
|
-
return this._runBare(task);
|
|
320
|
+
return this._runBare(task, options);
|
|
317
321
|
}
|
|
318
322
|
|
|
319
323
|
/** Legacy session service path; falls back to the bare report. */
|
|
@@ -336,7 +340,7 @@ export class SessionRunner {
|
|
|
336
340
|
return this._runBare(task);
|
|
337
341
|
}
|
|
338
342
|
|
|
339
|
-
_runBare(task) {
|
|
343
|
+
_runBare(task, execOptions = {}) {
|
|
340
344
|
return {
|
|
341
345
|
output: `[dsh-cron] Prompt triggered: "${task.prompt.slice(0, 100)}..."`,
|
|
342
346
|
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
@@ -344,9 +348,18 @@ export class SessionRunner {
|
|
|
344
348
|
};
|
|
345
349
|
}
|
|
346
350
|
|
|
347
|
-
async _runAgentSession(task, runtime, signal) {
|
|
351
|
+
async _runAgentSession(task, runtime, signal, execOptions = {}) {
|
|
348
352
|
try {
|
|
349
|
-
|
|
353
|
+
let effectiveTask = task;
|
|
354
|
+
if (execOptions?.prevOutput) {
|
|
355
|
+
const prev = String(execOptions.prevOutput);
|
|
356
|
+
const prompt = task.prompt || '';
|
|
357
|
+
const newPrompt = prompt.includes('{{prevOutput}}')
|
|
358
|
+
? prompt.replaceAll('{{prevOutput}}', prev)
|
|
359
|
+
: `[Previous Output]:\n${prev}\n\n${prompt}`;
|
|
360
|
+
effectiveTask = { ...task, prompt: newPrompt };
|
|
361
|
+
}
|
|
362
|
+
const prep = await this._prepareAgentRun(effectiveTask, runtime, signal);
|
|
350
363
|
return await this._executeAgentTurn(task, prep);
|
|
351
364
|
} catch (err) {
|
|
352
365
|
console.error(`[dsh-cron] agent execution failed:`, err.message);
|