@goodandready/dsh-cron 0.1.23 → 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 -35
- package/lib/chat-start.js +2 -2
- package/lib/client.js +668 -412
- 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 +70 -39
- package/lib/scheduler.js +47 -31
- package/lib/store.js +5 -2
- 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,14 +45,18 @@ 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');
|
|
52
52
|
return new Promise((resolve, reject) => {
|
|
53
|
+
const shellPath = process.platform === 'win32' ? undefined : (process.env.SHELL || '/bin/bash');
|
|
53
54
|
const child = exec(task.prompt, {
|
|
54
55
|
cwd: process.cwd(),
|
|
55
56
|
signal,
|
|
57
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
58
|
+
shell: shellPath,
|
|
59
|
+
env: { ...process.env },
|
|
56
60
|
}, (err, stdout, stderr) => {
|
|
57
61
|
if (err) {
|
|
58
62
|
if (signal && signal.aborted) {
|
|
@@ -97,50 +101,77 @@ export class SessionRunner {
|
|
|
97
101
|
|
|
98
102
|
const defaultSel = this.ctx.get?.('agentDefaultModel')?.currentSelection?.();
|
|
99
103
|
const provider = task.provider || defaultSel?.provider;
|
|
100
|
-
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
|
+
};
|
|
101
123
|
|
|
102
124
|
const cwd = process.cwd();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
throw (signal.reason || new Error('Aborted'));
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
await handle.agent.whenIdle();
|
|
114
|
-
handle.agent.followup(createUserMessage({
|
|
115
|
-
content: [{ type: 'text', text: task.prompt }],
|
|
116
|
-
source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
|
|
117
|
-
}));
|
|
125
|
+
let handle = null;
|
|
126
|
+
try {
|
|
127
|
+
handle = await this.ctx.agents.create({
|
|
128
|
+
sessionId: mintSessionId(),
|
|
129
|
+
meta: { cwd, ephemeral: true, internal: true },
|
|
130
|
+
agentOptions: provider && model ? { provider, model } : undefined,
|
|
131
|
+
});
|
|
118
132
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
}));
|
|
123
138
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const sessionUsage = handle.agent.session?.usage || handle.agent.usage;
|
|
128
|
-
if (sessionUsage) {
|
|
129
|
-
usage = {
|
|
130
|
-
inputTokens: sessionUsage.inputTokens || sessionUsage.promptTokens || 0,
|
|
131
|
-
outputTokens: sessionUsage.outputTokens || sessionUsage.completionTokens || 0,
|
|
132
|
-
cacheReadTokens: sessionUsage.cacheReadTokens || sessionUsage.cachedTokens || 0,
|
|
133
|
-
};
|
|
139
|
+
// Await agent turn completion (#67), abortable on timeout/overlap (#88)
|
|
140
|
+
if (typeof handle.agent.whenIdle === 'function') {
|
|
141
|
+
await raceAbort(handle.agent.whenIdle());
|
|
134
142
|
}
|
|
135
|
-
} catch {}
|
|
136
143
|
|
|
137
|
-
|
|
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 {}
|
|
138
156
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
};
|
|
164
|
+
} finally {
|
|
165
|
+
try {
|
|
166
|
+
if (handle && typeof handle.dispose === 'function') {
|
|
167
|
+
await handle.dispose();
|
|
168
|
+
} else if (handle && handle.agent && typeof handle.agent.destroy === 'function') {
|
|
169
|
+
await handle.agent.destroy();
|
|
170
|
+
}
|
|
171
|
+
} catch (disposeErr) {
|
|
172
|
+
console.warn('[dsh-cron] session dispose notice:', disposeErr.message);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
144
175
|
} catch (err) {
|
|
145
176
|
console.error(`[dsh-cron] agent execution failed:`, err.message);
|
|
146
177
|
throw err;
|
|
@@ -154,7 +185,7 @@ export class SessionRunner {
|
|
|
154
185
|
title: `[Cron] ${task.title}`,
|
|
155
186
|
ephemeral: task.delivery === 'isolated',
|
|
156
187
|
});
|
|
157
|
-
|
|
188
|
+
|
|
158
189
|
if (session && typeof session.send === 'function') {
|
|
159
190
|
const result = await session.send(task.prompt);
|
|
160
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
|
|
|
@@ -135,8 +136,23 @@ export class TaskScheduler {
|
|
|
135
136
|
|
|
136
137
|
start() {
|
|
137
138
|
const tasks = this.store.list();
|
|
139
|
+
const now = Date.now();
|
|
138
140
|
for (const task of tasks) {
|
|
139
141
|
if (task.status === 'active') {
|
|
142
|
+
// Detect missed execution if daemon was offline (threshold: > 5 minutes)
|
|
143
|
+
if (task.nextRunAt && (now - task.nextRunAt) > 5 * 60 * 1000) {
|
|
144
|
+
const missedAt = task.nextRunAt;
|
|
145
|
+
console.warn(`[dsh-cron] Task "${task.title}" (${task.id}) missed scheduled run at ${new Date(missedAt).toISOString()}`);
|
|
146
|
+
this.store.recordRun(task.id, {
|
|
147
|
+
at: missedAt,
|
|
148
|
+
status: 'missed',
|
|
149
|
+
durationMs: 0,
|
|
150
|
+
output: `Skipped: the service was offline or restarting at the scheduled time (${new Date(missedAt).toISOString()})`,
|
|
151
|
+
error: null,
|
|
152
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
153
|
+
costUsd: 0,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
140
156
|
this.scheduleTask(task);
|
|
141
157
|
}
|
|
142
158
|
}
|
|
@@ -159,7 +175,7 @@ export class TaskScheduler {
|
|
|
159
175
|
|
|
160
176
|
for (const [, run] of this.running.entries()) {
|
|
161
177
|
try {
|
|
162
|
-
run.controller.abort(new Error('
|
|
178
|
+
run.controller.abort(new Error('Scheduler stopped'));
|
|
163
179
|
} catch {}
|
|
164
180
|
}
|
|
165
181
|
this.running.clear();
|
|
@@ -230,7 +246,7 @@ export class TaskScheduler {
|
|
|
230
246
|
at: Date.now(),
|
|
231
247
|
status: 'skipped',
|
|
232
248
|
durationMs: 0,
|
|
233
|
-
output: '
|
|
249
|
+
output: 'Skipped: the previous run is still in progress (overlapPolicy: skip)',
|
|
234
250
|
error: null,
|
|
235
251
|
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
|
|
236
252
|
costUsd: 0,
|
|
@@ -239,7 +255,7 @@ export class TaskScheduler {
|
|
|
239
255
|
}
|
|
240
256
|
if (overlapPolicy === 'replace') {
|
|
241
257
|
console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, canceling current run (overlapPolicy: replace)`);
|
|
242
|
-
active.controller.abort(new Error('
|
|
258
|
+
active.controller.abort(new Error('Aborted by overlap policy: replace'));
|
|
243
259
|
}
|
|
244
260
|
if (overlapPolicy === 'queue') {
|
|
245
261
|
console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, queueing next run (overlapPolicy: queue)`);
|
|
@@ -324,14 +340,14 @@ export class TaskScheduler {
|
|
|
324
340
|
if (shouldCreateKanbanCard(task, runInfo)) {
|
|
325
341
|
const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
|
|
326
342
|
const isErr = runInfo.status === 'error' || runInfo.status === 'timeout';
|
|
327
|
-
const cardTitle = isErr ? `[
|
|
343
|
+
const cardTitle = isErr ? `[Cron failure] ${task.title}` : `[Cron completed] ${task.title}`;
|
|
328
344
|
const cardBody = [
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
runInfo.error ? `\n
|
|
334
|
-
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\`\`\`` : '',
|
|
335
351
|
].filter(Boolean).join('\n\n');
|
|
336
352
|
|
|
337
353
|
createKanbanCard({
|
|
@@ -391,7 +407,7 @@ export class TaskScheduler {
|
|
|
391
407
|
const running = this.running.get(taskId);
|
|
392
408
|
if (running) {
|
|
393
409
|
try {
|
|
394
|
-
running.controller.abort(new Error('
|
|
410
|
+
running.controller.abort(new Error('Task paused'));
|
|
395
411
|
} catch {}
|
|
396
412
|
this.running.delete(taskId);
|
|
397
413
|
}
|
|
@@ -430,7 +446,7 @@ export class TaskScheduler {
|
|
|
430
446
|
const running = this.running.get(taskId);
|
|
431
447
|
if (running) {
|
|
432
448
|
try {
|
|
433
|
-
running.controller.abort(new Error('
|
|
449
|
+
running.controller.abort(new Error('Task deleted'));
|
|
434
450
|
} catch {}
|
|
435
451
|
this.running.delete(taskId);
|
|
436
452
|
}
|
package/lib/store.js
CHANGED
|
@@ -13,6 +13,7 @@ export class TaskStore {
|
|
|
13
13
|
this.tasks = new Map();
|
|
14
14
|
this.history = new Map();
|
|
15
15
|
this.settings = {};
|
|
16
|
+
this._savePromise = Promise.resolve();
|
|
16
17
|
this.init();
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -57,7 +58,9 @@ export class TaskStore {
|
|
|
57
58
|
history: Object.fromEntries(this.history.entries()),
|
|
58
59
|
settings: this.settings || {},
|
|
59
60
|
};
|
|
60
|
-
|
|
61
|
+
// Unique tmp file per save call with process PID and random token to eliminate file collision
|
|
62
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
63
|
+
const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
|
|
61
64
|
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
|
62
65
|
fs.renameSync(tmp, this.filePath);
|
|
63
66
|
} catch (err) {
|
|
@@ -147,7 +150,7 @@ export class TaskStore {
|
|
|
147
150
|
...prev,
|
|
148
151
|
...task,
|
|
149
152
|
id,
|
|
150
|
-
title: task.title !== undefined ? task.title : (prev.title || '
|
|
153
|
+
title: task.title !== undefined ? task.title : (prev.title || 'New task'),
|
|
151
154
|
schedule: task.schedule !== undefined ? task.schedule : (prev.schedule || '0 9 * * *'),
|
|
152
155
|
scheduleText: task.scheduleText !== undefined ? task.scheduleText : (prev.scheduleText || task.schedule),
|
|
153
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
|
|