@goodandready/dsh-cron 0.1.24 → 0.2.0
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 +117 -82
- package/docs/README.ru.md +133 -85
- package/docs/README.zh.md +140 -82
- package/docs/design/DESIGN.md +44 -36
- package/lib/chat-start.js +2 -2
- package/lib/client.js +647 -409
- package/lib/http-utils.js +81 -0
- package/lib/index.js +342 -384
- package/lib/integrations.js +0 -1
- package/lib/prompt.js +42 -40
- package/lib/runner.js +48 -34
- package/lib/scheduler.js +33 -32
- package/lib/store.js +1 -1
- package/lib/telegram.js +27 -13
- package/package.json +1 -1
package/lib/integrations.js
CHANGED
package/lib/prompt.js
CHANGED
|
@@ -1,54 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Builds the initial prompt for starting a session with DSH agent to configure a cron task.
|
|
3
|
-
*
|
|
3
|
+
* English is the canonical source language; the conversation language follows
|
|
4
|
+
* the user's DSH locale via the translation plugin.
|
|
5
|
+
* @param {string} userText
|
|
4
6
|
* @returns {string}
|
|
5
7
|
*/
|
|
6
8
|
export function buildAgentCronPrompt(userText) {
|
|
7
9
|
const text = (userText || '').trim();
|
|
8
|
-
return
|
|
9
|
-
|
|
10
|
+
return `The user wants to set up a scheduled (cron) task via DSH Cron.
|
|
11
|
+
The user's original request:
|
|
10
12
|
"${text}"
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
1.
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
CRITICAL RULES FOR THE AGENT:
|
|
15
|
+
1. NEVER RUSH:
|
|
16
|
+
It is STRICTLY FORBIDDEN to call the \`cron_create_task\` (or \`cron_schedule_task\`) tool in your first reply!
|
|
17
|
+
You MUST NOT create the task until you have asked clarifying questions and received the user's confirmation.
|
|
16
18
|
|
|
17
|
-
2.
|
|
18
|
-
-
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
*
|
|
22
|
-
-
|
|
19
|
+
2. FIRST QUESTION: LLM vs NO-LLM Shell (critical):
|
|
20
|
+
- Evaluate the nature of the task:
|
|
21
|
+
* If the task is TRIVIAL (checking an RSS/Atom feed, checking an HTTP status code, curl/ping/git checks, monitoring a file or a service) — you MUST recommend the NO-LLM Shell variant (type: "script")!
|
|
22
|
+
Explain to the user: "A language model is overkill for this task — a simple bash script with curl/jq completes in milliseconds, is more reliable, and costs zero tokens. I recommend the NO-LLM Shell script variant."
|
|
23
|
+
* If the task requires SEMANTIC ANALYSIS (summarizing messages, generating a report from unstructured text, making judgement calls) — propose an LLM task (type: "llm").
|
|
24
|
+
- Ask the user whether they agree with the proposed task type.
|
|
23
25
|
|
|
24
|
-
3.
|
|
25
|
-
-
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
-
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
-
|
|
32
|
-
*
|
|
33
|
-
- Silent Rule (
|
|
34
|
-
*
|
|
35
|
-
*
|
|
26
|
+
3. WHAT YOU MUST CLARIFY:
|
|
27
|
+
- If NO-LLM Shell was chosen:
|
|
28
|
+
* Propose a ready-made shell script or command to execute.
|
|
29
|
+
* Confirm that the script logic suits the user.
|
|
30
|
+
- If LLM was chosen:
|
|
31
|
+
* Propose an economical model from those available in the user's DSH installation and explain why it fits this task.
|
|
32
|
+
* Ask whether this model works for the user.
|
|
33
|
+
- The exact schedule:
|
|
34
|
+
* Propose a concrete cron expression (for example, "0 */6 * * *" for 4 times a day, or "0 9 * * 1-5" for weekdays) and a plain-language explanation.
|
|
35
|
+
- Silent Rule (notifications):
|
|
36
|
+
* For checks and monitoring, apply the strict Silent Rule: when there are NO new events or changes — FULL SILENCE, no messages to chat/Telegram at all (exit 0 in scripts, or an empty reply). An alert is sent ONLY when something new is found or a failure occurs.
|
|
37
|
+
* Confirm to the user that the silent rule will be enabled.
|
|
36
38
|
|
|
37
|
-
4.
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
•
|
|
41
|
-
•
|
|
42
|
-
•
|
|
43
|
-
• Silent Rule: [
|
|
44
|
-
-
|
|
39
|
+
4. STRUCTURE OF THE FIRST REPLY:
|
|
40
|
+
- Briefly confirm the essence of the task.
|
|
41
|
+
- Present the draft task card:
|
|
42
|
+
• Type: [NO-LLM Shell (recommended) / LLM]
|
|
43
|
+
• Schedule: [cron expression and a plain-language description]
|
|
44
|
+
• Implementation / Script / Model: [script code or the proposed model]
|
|
45
|
+
• Silent Rule: [only on new events / failures]
|
|
46
|
+
- Ask any remaining questions and ask the user to confirm task creation.
|
|
45
47
|
|
|
46
|
-
5.
|
|
47
|
-
|
|
48
|
-
- title:
|
|
49
|
-
- schedule:
|
|
50
|
-
- prompt:
|
|
51
|
-
- type: "script"
|
|
52
|
-
- provider
|
|
48
|
+
5. CREATING THE TASK:
|
|
49
|
+
Only AFTER the user replies and confirms the parameters, call the \`cron_create_task\` tool with:
|
|
50
|
+
- title: a clear task name
|
|
51
|
+
- schedule: the agreed cron expression
|
|
52
|
+
- prompt: the final shell command/script path (for type="script") OR detailed instructions including the Silent Rule (for type="llm")
|
|
53
|
+
- type: "script" or "llm"
|
|
54
|
+
- provider and model: the agreed provider and model (only if type="llm")
|
|
53
55
|
- delivery: "isolated"`;
|
|
54
56
|
}
|
package/lib/runner.js
CHANGED
|
@@ -45,7 +45,7 @@ export class SessionRunner {
|
|
|
45
45
|
|
|
46
46
|
async _run(task, signal) {
|
|
47
47
|
console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
|
|
48
|
-
|
|
48
|
+
|
|
49
49
|
// 1. Direct shell / script task fallback (NO-LLM)
|
|
50
50
|
if (task.type === 'script' || task.mode === 'no-llm') {
|
|
51
51
|
const { exec } = await import('node:child_process');
|
|
@@ -101,7 +101,25 @@ export class SessionRunner {
|
|
|
101
101
|
|
|
102
102
|
const defaultSel = this.ctx.get?.('agentDefaultModel')?.currentSelection?.();
|
|
103
103
|
const provider = task.provider || defaultSel?.provider;
|
|
104
|
-
const model = task.model || defaultSel?.model ||
|
|
104
|
+
const model = task.model || defaultSel?.model || undefined;
|
|
105
|
+
|
|
106
|
+
// The agent session cannot take the AbortSignal natively, so any abort
|
|
107
|
+
// (timeout, overlap replace, pause, scheduler stop) races against the
|
|
108
|
+
// session awaits and forces an immediate dispose (#88).
|
|
109
|
+
const abortError = () => (signal && signal.reason) || new Error('Task aborted');
|
|
110
|
+
const raceAbort = (promise) => {
|
|
111
|
+
if (!signal) return promise;
|
|
112
|
+
return Promise.race([
|
|
113
|
+
promise,
|
|
114
|
+
new Promise((_, reject) => {
|
|
115
|
+
if (signal.aborted) {
|
|
116
|
+
reject(abortError());
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
signal.addEventListener('abort', () => reject(abortError()), { once: true });
|
|
120
|
+
}),
|
|
121
|
+
]);
|
|
122
|
+
};
|
|
105
123
|
|
|
106
124
|
const cwd = process.cwd();
|
|
107
125
|
let handle = null;
|
|
@@ -112,41 +130,37 @@ export class SessionRunner {
|
|
|
112
130
|
agentOptions: provider && model ? { provider, model } : undefined,
|
|
113
131
|
});
|
|
114
132
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
133
|
+
await raceAbort(handle.agent.whenIdle());
|
|
134
|
+
handle.agent.followup(createUserMessage({
|
|
135
|
+
content: [{ type: 'text', text: task.prompt }],
|
|
136
|
+
source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
|
|
137
|
+
}));
|
|
118
138
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
|
|
123
|
-
}));
|
|
124
|
-
|
|
125
|
-
// Await agent turn completion (#67)
|
|
126
|
-
if (typeof handle.agent.whenIdle === 'function') {
|
|
127
|
-
await handle.agent.whenIdle();
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Try extracting token usage from session history if available
|
|
131
|
-
let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
|
|
132
|
-
try {
|
|
133
|
-
const sessionUsage = handle.agent.session?.usage || handle.agent.usage;
|
|
134
|
-
if (sessionUsage) {
|
|
135
|
-
usage = {
|
|
136
|
-
inputTokens: sessionUsage.inputTokens || sessionUsage.promptTokens || 0,
|
|
137
|
-
outputTokens: sessionUsage.outputTokens || sessionUsage.completionTokens || 0,
|
|
138
|
-
cacheReadTokens: sessionUsage.cacheReadTokens || sessionUsage.cachedTokens || 0,
|
|
139
|
-
};
|
|
139
|
+
// Await agent turn completion (#67), abortable on timeout/overlap (#88)
|
|
140
|
+
if (typeof handle.agent.whenIdle === 'function') {
|
|
141
|
+
await raceAbort(handle.agent.whenIdle());
|
|
140
142
|
}
|
|
141
|
-
} catch {}
|
|
142
143
|
|
|
143
|
-
|
|
144
|
+
// Try extracting token usage from session history if available
|
|
145
|
+
let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
|
|
146
|
+
try {
|
|
147
|
+
const sessionUsage = handle.agent.session?.usage || handle.agent.usage;
|
|
148
|
+
if (sessionUsage) {
|
|
149
|
+
usage = {
|
|
150
|
+
inputTokens: sessionUsage.inputTokens || sessionUsage.promptTokens || 0,
|
|
151
|
+
outputTokens: sessionUsage.outputTokens || sessionUsage.completionTokens || 0,
|
|
152
|
+
cacheReadTokens: sessionUsage.cacheReadTokens || sessionUsage.cachedTokens || 0,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
} catch {}
|
|
144
156
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
157
|
+
const costUsd = estimateTokenCost(model, usage);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
output: `[dsh-cron] Agent finished turn for task "${task.title}" (session: ${handle.agent.session?.id || 'cron'})`,
|
|
161
|
+
usage,
|
|
162
|
+
costUsd
|
|
163
|
+
};
|
|
150
164
|
} finally {
|
|
151
165
|
try {
|
|
152
166
|
if (handle && typeof handle.dispose === 'function') {
|
|
@@ -171,7 +185,7 @@ export class SessionRunner {
|
|
|
171
185
|
title: `[Cron] ${task.title}`,
|
|
172
186
|
ephemeral: task.delivery === 'isolated',
|
|
173
187
|
});
|
|
174
|
-
|
|
188
|
+
|
|
175
189
|
if (session && typeof session.send === 'function') {
|
|
176
190
|
const result = await session.send(task.prompt);
|
|
177
191
|
const out = typeof result === 'string' ? result : JSON.stringify(result);
|
package/lib/scheduler.js
CHANGED
|
@@ -10,15 +10,15 @@ export function describeCron(cronPattern) {
|
|
|
10
10
|
const parts = cronPattern.trim().split(/\s+/);
|
|
11
11
|
if (parts.length === 5) {
|
|
12
12
|
const [m, h, dom, mon, dow] = parts;
|
|
13
|
-
if (m.startsWith('*/')) return
|
|
13
|
+
if (m.startsWith('*/')) return `Every ${m.slice(2)} minutes`;
|
|
14
14
|
if (dow === '1-5' && dom === '*' && mon === '*') {
|
|
15
|
-
return
|
|
15
|
+
return `Weekdays at ${pad(h)}:${pad(m)}`;
|
|
16
16
|
}
|
|
17
17
|
if (dow === '*' && dom === '*' && mon === '*') {
|
|
18
|
-
return
|
|
18
|
+
return `Every day at ${pad(h)}:${pad(m)}`;
|
|
19
19
|
}
|
|
20
20
|
if (dow === '5' && dom === '*' && mon === '*') {
|
|
21
|
-
return
|
|
21
|
+
return `Fridays at ${pad(h)}:${pad(m)}`;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
return cronPattern;
|
|
@@ -26,7 +26,7 @@ export function describeCron(cronPattern) {
|
|
|
26
26
|
|
|
27
27
|
export function parseScheduleExpression(input) {
|
|
28
28
|
const str = String(input || '').trim();
|
|
29
|
-
if (!str) throw new Error('
|
|
29
|
+
if (!str) throw new Error('Schedule expression must not be empty');
|
|
30
30
|
|
|
31
31
|
// 1. One-shot ISO 8601 timestamp or explicit "at: <ISO/datetime>"
|
|
32
32
|
const atPrefixMatch = str.match(/^(?:at:\s*|at\s+)(.+)$/i);
|
|
@@ -38,34 +38,35 @@ export function parseScheduleExpression(input) {
|
|
|
38
38
|
cronPattern: null,
|
|
39
39
|
isOneShot: true,
|
|
40
40
|
targetTimestamp: targetMs,
|
|
41
|
-
humanText:
|
|
41
|
+
humanText: `One-shot at ${parsedDate.toISOString()}`,
|
|
42
42
|
nextRun: targetMs
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// 2. Relative one-shot: "in 20m", "in 2h", "in 30s", "in 1d", "через 15 минут"
|
|
47
|
+
// Russian keywords are accepted input aliases, not display strings.
|
|
47
48
|
const inRelMatch = str.match(/^(?:in\s+|через\s+)(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?|мин|минут|часа?|часов)?$/i);
|
|
48
49
|
if (inRelMatch) {
|
|
49
50
|
const num = parseInt(inRelMatch[1], 10);
|
|
50
51
|
const unit = (inRelMatch[2] || 'm').toLowerCase();
|
|
51
52
|
let delayMs = num * 60 * 1000;
|
|
52
|
-
let unitText = `${num}
|
|
53
|
+
let unitText = `${num} min`;
|
|
53
54
|
if (unit.startsWith('s')) {
|
|
54
55
|
delayMs = num * 1000;
|
|
55
|
-
unitText = `${num}
|
|
56
|
+
unitText = `${num} sec`;
|
|
56
57
|
} else if (unit.startsWith('h') || unit.startsWith('час')) {
|
|
57
58
|
delayMs = num * 3600 * 1000;
|
|
58
|
-
unitText = `${num}
|
|
59
|
+
unitText = `${num} h`;
|
|
59
60
|
} else if (unit.startsWith('d')) {
|
|
60
61
|
delayMs = num * 86400 * 1000;
|
|
61
|
-
unitText = `${num}
|
|
62
|
+
unitText = `${num} d`;
|
|
62
63
|
}
|
|
63
64
|
const targetMs = Date.now() + delayMs;
|
|
64
65
|
return {
|
|
65
66
|
cronPattern: null,
|
|
66
67
|
isOneShot: true,
|
|
67
68
|
targetTimestamp: targetMs,
|
|
68
|
-
humanText:
|
|
69
|
+
humanText: `One-shot in ${unitText} (at ${new Date(targetMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`,
|
|
69
70
|
nextRun: targetMs
|
|
70
71
|
};
|
|
71
72
|
}
|
|
@@ -78,36 +79,36 @@ export function parseScheduleExpression(input) {
|
|
|
78
79
|
if (unit.startsWith('m')) {
|
|
79
80
|
return {
|
|
80
81
|
cronPattern: `*/${num} * * * *`,
|
|
81
|
-
humanText:
|
|
82
|
+
humanText: `Every ${num} minutes`,
|
|
82
83
|
isInterval: true,
|
|
83
84
|
};
|
|
84
85
|
}
|
|
85
86
|
if (unit.startsWith('h')) {
|
|
86
87
|
return {
|
|
87
88
|
cronPattern: `0 */${num} * * *`,
|
|
88
|
-
humanText:
|
|
89
|
+
humanText: `Every ${num} hours`,
|
|
89
90
|
isInterval: true,
|
|
90
91
|
};
|
|
91
92
|
}
|
|
92
93
|
if (unit.startsWith('d')) {
|
|
93
94
|
return {
|
|
94
95
|
cronPattern: `0 0 */${num} * *`,
|
|
95
|
-
humanText:
|
|
96
|
+
humanText: `Every ${num} days`,
|
|
96
97
|
isInterval: true,
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
// 4. Friendly recurring aliases
|
|
102
|
+
// 4. Friendly recurring aliases (English plus accepted Russian input aliases)
|
|
102
103
|
const lower = str.toLowerCase();
|
|
103
104
|
if (lower === 'daily' || lower === 'каждый день') {
|
|
104
|
-
return { cronPattern: '0 9 * * *', humanText: '
|
|
105
|
+
return { cronPattern: '0 9 * * *', humanText: 'Every day at 09:00' };
|
|
105
106
|
}
|
|
106
107
|
if (lower === 'weekdays' || lower === 'в будние дни' || lower === 'по будням') {
|
|
107
|
-
return { cronPattern: '0 9 * * 1-5', humanText: '
|
|
108
|
+
return { cronPattern: '0 9 * * 1-5', humanText: 'Weekdays at 09:00' };
|
|
108
109
|
}
|
|
109
110
|
if (lower === 'hourly' || lower === 'каждый час') {
|
|
110
|
-
return { cronPattern: '0 * * * *', humanText: '
|
|
111
|
+
return { cronPattern: '0 * * * *', humanText: 'Every hour' };
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
// 5. Standard 5-field cron expression
|
|
@@ -120,7 +121,7 @@ export function parseScheduleExpression(input) {
|
|
|
120
121
|
nextRun: next ? next.getTime() : null,
|
|
121
122
|
};
|
|
122
123
|
} catch (err) {
|
|
123
|
-
throw new Error('
|
|
124
|
+
throw new Error('Invalid cron schedule: ' + err.message);
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
|
|
@@ -146,7 +147,7 @@ export class TaskScheduler {
|
|
|
146
147
|
at: missedAt,
|
|
147
148
|
status: 'missed',
|
|
148
149
|
durationMs: 0,
|
|
149
|
-
output:
|
|
150
|
+
output: `Skipped: the service was offline or restarting at the scheduled time (${new Date(missedAt).toISOString()})`,
|
|
150
151
|
error: null,
|
|
151
152
|
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
152
153
|
costUsd: 0,
|
|
@@ -174,7 +175,7 @@ export class TaskScheduler {
|
|
|
174
175
|
|
|
175
176
|
for (const [, run] of this.running.entries()) {
|
|
176
177
|
try {
|
|
177
|
-
run.controller.abort(new Error('
|
|
178
|
+
run.controller.abort(new Error('Scheduler stopped'));
|
|
178
179
|
} catch {}
|
|
179
180
|
}
|
|
180
181
|
this.running.clear();
|
|
@@ -245,7 +246,7 @@ export class TaskScheduler {
|
|
|
245
246
|
at: Date.now(),
|
|
246
247
|
status: 'skipped',
|
|
247
248
|
durationMs: 0,
|
|
248
|
-
output: '
|
|
249
|
+
output: 'Skipped: the previous run is still in progress (overlapPolicy: skip)',
|
|
249
250
|
error: null,
|
|
250
251
|
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
251
252
|
costUsd: 0,
|
|
@@ -254,7 +255,7 @@ export class TaskScheduler {
|
|
|
254
255
|
}
|
|
255
256
|
if (overlapPolicy === 'replace') {
|
|
256
257
|
console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, canceling current run (overlapPolicy: replace)`);
|
|
257
|
-
active.controller.abort(new Error('
|
|
258
|
+
active.controller.abort(new Error('Aborted by overlap policy: replace'));
|
|
258
259
|
}
|
|
259
260
|
if (overlapPolicy === 'queue') {
|
|
260
261
|
console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, queueing next run (overlapPolicy: queue)`);
|
|
@@ -339,14 +340,14 @@ export class TaskScheduler {
|
|
|
339
340
|
if (shouldCreateKanbanCard(task, runInfo)) {
|
|
340
341
|
const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
|
|
341
342
|
const isErr = runInfo.status === 'error' || runInfo.status === 'timeout';
|
|
342
|
-
const cardTitle = isErr ? `[
|
|
343
|
+
const cardTitle = isErr ? `[Cron failure] ${task.title}` : `[Cron completed] ${task.title}`;
|
|
343
344
|
const cardBody = [
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
runInfo.error ? `\n
|
|
349
|
-
runInfo.output ? `\n
|
|
345
|
+
`**Cron task:** ${task.title} (\`${task.id}\`)`,
|
|
346
|
+
`**Schedule:** ${task.scheduleText || task.schedule}`,
|
|
347
|
+
`**Status:** ${runInfo.status}`,
|
|
348
|
+
`**Started at:** ${new Date(runInfo.at).toISOString()}`,
|
|
349
|
+
runInfo.error ? `\n**Error:**\n\`\`\`\n${runInfo.error}\n\`\`\`` : '',
|
|
350
|
+
runInfo.output ? `\n**Log / Output:**\n\`\`\`\n${runInfo.output.slice(0, 1000)}\n\`\`\`` : '',
|
|
350
351
|
].filter(Boolean).join('\n\n');
|
|
351
352
|
|
|
352
353
|
createKanbanCard({
|
|
@@ -406,7 +407,7 @@ export class TaskScheduler {
|
|
|
406
407
|
const running = this.running.get(taskId);
|
|
407
408
|
if (running) {
|
|
408
409
|
try {
|
|
409
|
-
running.controller.abort(new Error('
|
|
410
|
+
running.controller.abort(new Error('Task paused'));
|
|
410
411
|
} catch {}
|
|
411
412
|
this.running.delete(taskId);
|
|
412
413
|
}
|
|
@@ -445,7 +446,7 @@ export class TaskScheduler {
|
|
|
445
446
|
const running = this.running.get(taskId);
|
|
446
447
|
if (running) {
|
|
447
448
|
try {
|
|
448
|
-
running.controller.abort(new Error('
|
|
449
|
+
running.controller.abort(new Error('Task deleted'));
|
|
449
450
|
} catch {}
|
|
450
451
|
this.running.delete(taskId);
|
|
451
452
|
}
|
package/lib/store.js
CHANGED
|
@@ -150,7 +150,7 @@ export class TaskStore {
|
|
|
150
150
|
...prev,
|
|
151
151
|
...task,
|
|
152
152
|
id,
|
|
153
|
-
title: task.title !== undefined ? task.title : (prev.title || '
|
|
153
|
+
title: task.title !== undefined ? task.title : (prev.title || 'New task'),
|
|
154
154
|
schedule: task.schedule !== undefined ? task.schedule : (prev.schedule || '0 9 * * *'),
|
|
155
155
|
scheduleText: task.scheduleText !== undefined ? task.scheduleText : (prev.scheduleText || task.schedule),
|
|
156
156
|
prompt: task.prompt !== undefined ? task.prompt : (prev.prompt || ''),
|
package/lib/telegram.js
CHANGED
|
@@ -4,6 +4,8 @@ import os from 'node:os';
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Extract simple key from YAML text without external dependencies.
|
|
7
|
+
* Best-effort fallback only: the primary source for cross-plugin defaults is
|
|
8
|
+
* the Harness settings/credentials service (#51, #93).
|
|
7
9
|
*/
|
|
8
10
|
export function extractSettingFromYaml(text, section, key) {
|
|
9
11
|
if (!text) return undefined;
|
|
@@ -37,17 +39,26 @@ export function extractFirstAllowedChatId(text) {
|
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
41
|
* Read system DSH settings for fallback credentials.
|
|
42
|
+
* Fails soft with a logged warning so silent misconfiguration is visible (#93).
|
|
40
43
|
*/
|
|
41
44
|
export function getDshDefaultTelegramCredentials() {
|
|
42
45
|
try {
|
|
43
46
|
const p = path.join(process.env.HOME || os.homedir(), '.dsh', 'settings.yaml');
|
|
44
47
|
if (fs.existsSync(p)) {
|
|
45
|
-
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = fs.readFileSync(p, 'utf-8');
|
|
51
|
+
} catch (readErr) {
|
|
52
|
+
console.warn('[dsh-cron] default Telegram credentials: cannot read DSH settings.yaml:', readErr.message);
|
|
53
|
+
return { botToken: undefined, chatId: undefined };
|
|
54
|
+
}
|
|
46
55
|
const botToken = extractSettingFromYaml(raw, 'dsh-messenger-gateway', 'botToken');
|
|
47
56
|
const chatId = extractFirstAllowedChatId(raw);
|
|
48
57
|
return { botToken, chatId };
|
|
49
58
|
}
|
|
50
|
-
} catch {
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.warn('[dsh-cron] default Telegram credentials lookup failed:', err.message);
|
|
61
|
+
}
|
|
51
62
|
return { botToken: undefined, chatId: undefined };
|
|
52
63
|
}
|
|
53
64
|
|
|
@@ -61,29 +72,30 @@ export function escapeMarkdown(str) {
|
|
|
61
72
|
|
|
62
73
|
/**
|
|
63
74
|
* Format task execution report for Telegram.
|
|
75
|
+
* Dynamic values are escaped so titles/schedules cannot break the markup (#93).
|
|
64
76
|
*/
|
|
65
77
|
export function formatTaskTelegramMessage(task, runInfo) {
|
|
66
78
|
const isSuccess = runInfo.status === 'success';
|
|
67
79
|
const statusEmoji = isSuccess ? '✅' : '❌';
|
|
68
|
-
const statusText = isSuccess ? '
|
|
80
|
+
const statusText = isSuccess ? 'Success' : 'Failed';
|
|
69
81
|
const duration = runInfo.durationMs != null ? `${runInfo.durationMs} ms` : '—';
|
|
70
|
-
const title = task.title || '
|
|
71
|
-
const schedule = task.scheduleText || task.schedule || '';
|
|
82
|
+
const title = escapeMarkdown(task.title || 'Task');
|
|
83
|
+
const schedule = escapeMarkdown(task.scheduleText || task.schedule || '');
|
|
72
84
|
|
|
73
85
|
const lines = [
|
|
74
86
|
`⏰ *DSH Cron:* ${title}`,
|
|
75
|
-
|
|
76
|
-
schedule ?
|
|
77
|
-
|
|
87
|
+
`*Status:* ${statusEmoji} ${statusText}`,
|
|
88
|
+
schedule ? `*Schedule:* ${schedule}` : null,
|
|
89
|
+
`*Duration:* ${duration}`,
|
|
78
90
|
].filter(Boolean);
|
|
79
91
|
|
|
80
92
|
if (runInfo.error) {
|
|
81
93
|
const errText = String(runInfo.error).slice(0, 1500);
|
|
82
|
-
lines.push('', '
|
|
94
|
+
lines.push('', '*Error:*', '```', errText, '```');
|
|
83
95
|
} else if (runInfo.output && isSuccess) {
|
|
84
96
|
const outText = String(runInfo.output).trim().slice(0, 2000);
|
|
85
97
|
if (outText) {
|
|
86
|
-
lines.push('', '
|
|
98
|
+
lines.push('', '*Output:*', '```', outText, '```');
|
|
87
99
|
}
|
|
88
100
|
}
|
|
89
101
|
|
|
@@ -94,8 +106,8 @@ export function formatTaskTelegramMessage(task, runInfo) {
|
|
|
94
106
|
* Send telegram message via Bot API.
|
|
95
107
|
*/
|
|
96
108
|
export async function sendTelegramMessage({ botToken, chatId, text, parseMode = 'Markdown' }) {
|
|
97
|
-
if (!botToken) throw new Error('Telegram botToken
|
|
98
|
-
if (!chatId) throw new Error('Telegram chatId
|
|
109
|
+
if (!botToken) throw new Error('Telegram botToken is not configured');
|
|
110
|
+
if (!chatId) throw new Error('Telegram chatId is not configured');
|
|
99
111
|
|
|
100
112
|
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
|
101
113
|
const body = {
|
|
@@ -133,13 +145,15 @@ export async function sendTelegramMessage({ botToken, chatId, text, parseMode =
|
|
|
133
145
|
|
|
134
146
|
/**
|
|
135
147
|
* Determine if a notification should be sent for a given task and run result.
|
|
148
|
+
* "timeout" counts as a failure, matching the Kanban card policy (#89).
|
|
136
149
|
*/
|
|
137
150
|
export function shouldNotifyTask(task, runInfo, globalSettings = {}) {
|
|
138
151
|
const isEnabled = task.notifyTelegram ?? globalSettings.notifyTelegram ?? false;
|
|
139
152
|
if (!isEnabled) return false;
|
|
140
153
|
|
|
154
|
+
const failed = runInfo.status === 'error' || runInfo.status === 'timeout';
|
|
141
155
|
const onlyOnFail = task.onlyOnFailure ?? globalSettings.onlyOnFailure ?? false;
|
|
142
|
-
if (onlyOnFail &&
|
|
156
|
+
if (onlyOnFail && !failed) {
|
|
143
157
|
return false;
|
|
144
158
|
}
|
|
145
159
|
|