@goodandready/dsh-cron 0.3.18 → 0.3.19
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/lib/analytics-digest.js +0 -4
- package/lib/chat-start.js +7 -1
- package/lib/client.css +226 -0
- package/lib/client.js +9 -402
- package/lib/dag.js +12 -2
- package/lib/http-utils.js +79 -40
- package/lib/locales.json +336 -0
- package/lib/routes.js +42 -0
- package/lib/secrets.js +1 -0
- package/lib/store.js +43 -3
- package/package.json +14 -3
package/lib/http-utils.js
CHANGED
|
@@ -124,21 +124,48 @@ export function getDshConnection(req, options = {}) {
|
|
|
124
124
|
export function getRequestRejection(req, options = {}) {
|
|
125
125
|
if (!req) return 403;
|
|
126
126
|
|
|
127
|
-
// 1. Validate Origin header if present: cannot be null, must match Host (CSRF guard)
|
|
128
127
|
const headers = req.headers || {};
|
|
129
128
|
const origin = headers.origin;
|
|
129
|
+
const referer = headers.referer;
|
|
130
130
|
const host = headers.host || '';
|
|
131
|
+
|
|
132
|
+
// 1. Check token authentication first: Bearer auth or custom header (#54, #86, #371)
|
|
133
|
+
const token = extractBearerToken(req);
|
|
134
|
+
const expectedToken = options.apiToken || process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
|
|
135
|
+
const isTokenAuthenticated = Boolean(expectedToken && token && token === expectedToken);
|
|
136
|
+
|
|
137
|
+
// 2. Check DSH connection service if available (#357)
|
|
138
|
+
const conn = getDshConnection(req, options);
|
|
139
|
+
let connRejection;
|
|
140
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
141
|
+
connRejection = conn.requestRejection(req);
|
|
142
|
+
if (connRejection === 403) {
|
|
143
|
+
return 403;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Valid Bearer / API token admits caller immediately without browser origin checks (#54, #371)
|
|
148
|
+
if (isTokenAuthenticated) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3. Validate Origin header if present: cannot be null, host must match Host (CSRF guard)
|
|
131
153
|
if (origin !== undefined) {
|
|
132
154
|
if (origin === 'null' || !origin) return 403;
|
|
133
155
|
try {
|
|
134
156
|
const parsed = new URL(origin);
|
|
135
|
-
if (
|
|
157
|
+
if (parsed.protocol === 'dsh-app:') {
|
|
158
|
+
const appHost = parsed.host.toLowerCase();
|
|
159
|
+
if (appHost && appHost !== 'app' && appHost !== 'localhost') return 403;
|
|
160
|
+
} else {
|
|
161
|
+
if (!parsed.host || !host || parsed.host.toLowerCase() !== host.toLowerCase()) return 403;
|
|
162
|
+
}
|
|
136
163
|
} catch {
|
|
137
164
|
return 403;
|
|
138
165
|
}
|
|
139
166
|
}
|
|
140
167
|
|
|
141
|
-
//
|
|
168
|
+
// 4. Validate Sec-Fetch-Site header if present
|
|
142
169
|
const site = headers['sec-fetch-site'];
|
|
143
170
|
if (site !== undefined) {
|
|
144
171
|
if (site !== 'same-origin' && site !== 'none') {
|
|
@@ -146,50 +173,62 @@ export function getRequestRejection(req, options = {}) {
|
|
|
146
173
|
}
|
|
147
174
|
}
|
|
148
175
|
|
|
149
|
-
//
|
|
150
|
-
const conn = getDshConnection(req, options);
|
|
151
|
-
let connRejection;
|
|
152
|
-
if (conn && typeof conn.requestRejection === 'function') {
|
|
153
|
-
connRejection = conn.requestRejection(req);
|
|
154
|
-
if (connRejection === 403) {
|
|
155
|
-
return 403;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// 4. Check token authentication
|
|
160
|
-
const token = extractBearerToken(req);
|
|
161
|
-
const expectedToken = options.apiToken || process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
|
|
162
|
-
const isTokenAuthenticated = Boolean(expectedToken && token && token === expectedToken);
|
|
163
|
-
|
|
164
|
-
// If DSH connection service admitted the request (undefined rejection)
|
|
165
|
-
const isConnectionAdmitted = Boolean(conn && connRejection === undefined);
|
|
166
|
-
|
|
167
|
-
// Check loopback address
|
|
176
|
+
// 5. Check socket loopback address & remote connection (#357)
|
|
168
177
|
const remote = req.socket?.remoteAddress || req.connection?.remoteAddress;
|
|
169
178
|
const hasRemote = Boolean(remote && typeof remote === 'string');
|
|
170
179
|
const isLoopback = Boolean(hasRemote && isLoopbackAddress(remote));
|
|
171
180
|
|
|
172
181
|
// FAIL-CLOSED AUTHENTICATION CHECK (#357):
|
|
173
182
|
if (conn) {
|
|
174
|
-
if (
|
|
175
|
-
return connRejection
|
|
183
|
+
if (connRejection !== undefined) {
|
|
184
|
+
return connRejection;
|
|
176
185
|
}
|
|
177
186
|
} else {
|
|
178
|
-
if (!
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
187
|
+
if (!hasRemote) {
|
|
188
|
+
return 401;
|
|
189
|
+
}
|
|
190
|
+
if (!isLoopback) {
|
|
182
191
|
return 403;
|
|
183
192
|
}
|
|
184
193
|
}
|
|
185
194
|
|
|
195
|
+
// 6. Source authorization gate (Issue #371):
|
|
196
|
+
// A loopback socket alone is not trusted for write actions or reading /tasks:
|
|
197
|
+
// dsh-lanmode proxies LAN/Tailscale clients into the host over loopback.
|
|
198
|
+
// Browsers attach Origin/Referer; non-browser calls without one are rejected fail-closed.
|
|
199
|
+
const method = String(req.method || 'GET').toUpperCase();
|
|
200
|
+
const isMutating = method !== '' && method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS';
|
|
201
|
+
const urlStr = String(req.url || '');
|
|
202
|
+
const isTasksRead = !isMutating && (urlStr === '/dsh-cron/tasks' || urlStr.startsWith('/dsh-cron/tasks?') || urlStr === '/tasks' || urlStr.startsWith('/tasks?'));
|
|
203
|
+
|
|
204
|
+
const hostStr = host.toLowerCase().split(':')[0];
|
|
205
|
+
const isLoopbackHost = hostStr === '' || hostStr === 'localhost' || hostStr === '127.0.0.1' || hostStr === '::1';
|
|
206
|
+
const requireSource = Boolean(options.requireSource || isMutating || (isTasksRead && !isLoopbackHost));
|
|
207
|
+
|
|
208
|
+
if (requireSource) {
|
|
209
|
+
if (!origin && !referer) {
|
|
210
|
+
return 403;
|
|
211
|
+
}
|
|
212
|
+
if (!origin && referer) {
|
|
213
|
+
try {
|
|
214
|
+
const parsedRef = new URL(referer);
|
|
215
|
+
if (parsedRef.protocol === 'dsh-app:') {
|
|
216
|
+
const appHost = parsedRef.host.toLowerCase();
|
|
217
|
+
if (appHost && appHost !== 'app' && appHost !== 'localhost') return 403;
|
|
218
|
+
} else {
|
|
219
|
+
if (!parsedRef.host || !host || parsedRef.host.toLowerCase() !== host.toLowerCase()) {
|
|
220
|
+
return 403;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
return 403;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
186
229
|
return undefined; // Admitted
|
|
187
230
|
}
|
|
188
231
|
|
|
189
|
-
/**
|
|
190
|
-
* Validate that an incoming request originates from a trusted local caller,
|
|
191
|
-
* an admitted DSH browser session, or an authenticated client (Issue #86, #357).
|
|
192
|
-
*/
|
|
193
232
|
export function isTrustedRequest(req, options = {}) {
|
|
194
233
|
return getRequestRejection(req, options) === undefined;
|
|
195
234
|
}
|
|
@@ -242,15 +281,15 @@ export function sendJson(res, statusCode, data) {
|
|
|
242
281
|
}
|
|
243
282
|
|
|
244
283
|
export function rejectCrossOrigin(req, res, options = {}) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const errorMsg = rejection === 401
|
|
248
|
-
? 'Unauthorized: authentication required'
|
|
249
|
-
: 'Forbidden: untrusted or cross-origin request';
|
|
250
|
-
sendJson(res, rejection, { ok: false, error: errorMsg });
|
|
251
|
-
return true;
|
|
284
|
+
if (isTrustedRequest(req, options)) {
|
|
285
|
+
return false;
|
|
252
286
|
}
|
|
253
|
-
|
|
287
|
+
const rejection = getRequestRejection(req, options) || 403;
|
|
288
|
+
const errorMsg = rejection === 401
|
|
289
|
+
? 'Unauthorized: authentication required'
|
|
290
|
+
: 'Forbidden: untrusted or cross-origin request';
|
|
291
|
+
sendJson(res, rejection, { ok: false, error: errorMsg });
|
|
292
|
+
return true;
|
|
254
293
|
}
|
|
255
294
|
|
|
256
295
|
export async function readBody(req, res) {
|
package/lib/locales.json
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
{
|
|
2
|
+
"zh": {
|
|
3
|
+
"sidebar.label": "定时任务",
|
|
4
|
+
"sidebar.jobsTitle": "运行中任务",
|
|
5
|
+
"sidebar.noActiveJobs": "无运行中任务",
|
|
6
|
+
"sidebar.moreJobs": "及另外 {count} 个",
|
|
7
|
+
"chip.title": "定时任务 (Cron)",
|
|
8
|
+
"panel.back": "返回对话",
|
|
9
|
+
"panel.title": "定时任务",
|
|
10
|
+
"panel.subtitle": "让 DSH 规划任务、设置提醒或跟踪状态",
|
|
11
|
+
"actions.create": "新建",
|
|
12
|
+
"actions.createWithDsh": "💬 让 DSH 创建",
|
|
13
|
+
"actions.setupManually": "✏️ 手动配置",
|
|
14
|
+
"actions.runNow": "立即运行",
|
|
15
|
+
"actions.runNowShort": "运行",
|
|
16
|
+
"actions.edit": "编辑 / 详情",
|
|
17
|
+
"actions.duplicate": "复制任务",
|
|
18
|
+
"actions.tuneWithDsh": "让 DSH 优化",
|
|
19
|
+
"tune.startFailed": "无法启动优化会话: {error}",
|
|
20
|
+
"actions.delete": "删除任务",
|
|
21
|
+
"duplicate.done": "已创建副本并处于暂停状态:恢复前请先核对调度时间。",
|
|
22
|
+
"duplicate.oneShotHint": "该副本为一次性任务且已暂停,请设定新的执行时间后再启用。",
|
|
23
|
+
"duplicate.failed": "复制任务失败: {error}",
|
|
24
|
+
"transfer.exportTitle": "导出任务",
|
|
25
|
+
"transfer.importTitle": "导入任务",
|
|
26
|
+
"transfer.exported": "已成功导出 {count} 个任务。",
|
|
27
|
+
"transfer.exportFailed": "导出失败: {error}",
|
|
28
|
+
"transfer.importTitleModal": "导入任务",
|
|
29
|
+
"transfer.importHint": "仅导出任务静态配置:不包含历史记录与计数器。渠道通过凭据名称引用;注意完整导出保留自定义环境变量和 HTTP 请求头。",
|
|
30
|
+
"transfer.exportPrompt": "请选择导出模式:\\n确定 = 净化导出(移除自定义环境变量与请求头)\\n取消 = 完整原始导出",
|
|
31
|
+
"transfer.strategyAdd": "作为新任务导入(分配新 ID)",
|
|
32
|
+
"transfer.strategyReplace": "覆盖同 ID 任务",
|
|
33
|
+
"transfer.strategySkip": "跳过已存在的同 ID 任务",
|
|
34
|
+
"transfer.summary": "该文件包含 {total} 个任务:{add} 个新增,{replace} 个覆盖,{skip} 个跳过。",
|
|
35
|
+
"transfer.confirm": "开始导入",
|
|
36
|
+
"transfer.imported": "已成功导出 {count} 个任务。",
|
|
37
|
+
"transfer.importFailed": "导入失败: {error}",
|
|
38
|
+
"transfer.badFile": "所选文件不是有效的 JSON 导出文件: {error}",
|
|
39
|
+
"actions.pause": "暂停",
|
|
40
|
+
"actions.resume": "恢复",
|
|
41
|
+
"actions.settings": "通知与渠道设置",
|
|
42
|
+
"actions.dryRun": "空跑测试",
|
|
43
|
+
"actions.dryRunTitle": "空跑测试(模拟执行,不产生副作用且不发送通知)",
|
|
44
|
+
"actions.archive": "历史归档",
|
|
45
|
+
"actions.archiveTitle": "查看执行历史与日志归档",
|
|
46
|
+
"dryRun.title": "🧪 空跑模拟结果",
|
|
47
|
+
"dryRun.task": "任务",
|
|
48
|
+
"dryRun.status": "模拟状态",
|
|
49
|
+
"dryRun.duration": "耗时",
|
|
50
|
+
"dryRun.output": "模拟输出",
|
|
51
|
+
"dryRun.preflight": "预检结果",
|
|
52
|
+
"dryRun.close": "关闭",
|
|
53
|
+
"archive.title": "📜 运行历史归档",
|
|
54
|
+
"archive.totalRuns": "共记录 {total} 次运行",
|
|
55
|
+
"archive.empty": "该任务暂无历史运行归档。",
|
|
56
|
+
"archive.loadMore": "加载更多 ({shown} / {total})",
|
|
57
|
+
"archive.close": "关闭",
|
|
58
|
+
"form.reliabilityTitle": "🛡️ 可靠性、心跳监听与自愈",
|
|
59
|
+
"form.preflightLabel": "预检健康探测",
|
|
60
|
+
"form.preflightNone": "无",
|
|
61
|
+
"form.preflightHttp": "HTTP (2xx 探测)",
|
|
62
|
+
"form.preflightShell": "Shell 探测 (退出码 0)",
|
|
63
|
+
"form.preflightDisk": "磁盘空间探测 (挂载点:最小百分比)",
|
|
64
|
+
"form.preflightTargetLabel": "预检目标参数",
|
|
65
|
+
"form.preflightTargetPlaceholder": "https://example.com/healthz 或 ls /data 或 /:10%",
|
|
66
|
+
"form.heartbeatIntervalLabel": "心跳打卡时限 (秒, 0 = 禁用)",
|
|
67
|
+
"form.gracePeriodLabel": "心跳宽限期 (秒)",
|
|
68
|
+
"form.priorityLabel": "队列优先级 (1-10,数值越大越先执行)",
|
|
69
|
+
"form.concurrencyGroupLabel": "并发分组名称",
|
|
70
|
+
"form.selfHealingLabel": "故障自愈脚本 (Shell)",
|
|
71
|
+
"form.selfHealingPlaceholder": "systemctl restart app || true",
|
|
72
|
+
"form.autoDiagnoseLabel": "任务失败时启用 AI 智能分析诊断",
|
|
73
|
+
"form.agentPresetLabel": "智能体预设 (Agent Preset)",
|
|
74
|
+
"form.agentPresetPlaceholder": "standard (留空使用全局默认预设)",
|
|
75
|
+
"form.agentPresetHint": "来自 dsh-agent-presets 的智能体预设(如 standard, minimal, cordis, ptc),挂载工作区工具与文件读写能力。",
|
|
76
|
+
"form.sessionTitleLabel": "会话标题模板 (Session Title)",
|
|
77
|
+
"form.sessionTitlePlaceholder": "如 {{date}} 市场早报 (支持 {{date}} 变量)",
|
|
78
|
+
"form.sessionTitleHint": "此任务运行生成的会话标题。支持 {{date}} (年-月-日)。留空默认为 \"<YYYY-MM-DD> <任务名>\"。",
|
|
79
|
+
"form.targetSessionIdLabel": "目标常驻会话 ID (Target Session ID)",
|
|
80
|
+
"form.targetSessionIdPlaceholder": "如 audit-chat 或 dev-summary(留空为单次隔离运行)",
|
|
81
|
+
"form.targetSessionIdHint": "配置后定时任务将在此常驻会话中持续交互,跨周期保留历史上下文对话记忆,不再每次新建孤立会话。",
|
|
82
|
+
"form.targetSessionResetLabel": "上下文轮转策略 (Session Rotation)",
|
|
83
|
+
"form.targetSessionResetNever": "永不轮转(单一会话持续累积)",
|
|
84
|
+
"form.targetSessionResetDaily": "每日轮转(每日自动开启新会话)",
|
|
85
|
+
"form.targetSessionResetWeekly": "每周轮转(每周自动开启新会话)",
|
|
86
|
+
"form.targetSessionResetHint": "自动按周期加注日期后缀轮转会话,有效防止长时间运行导致的模型上下文窗口超限与 Token 浪费。",
|
|
87
|
+
"form.previewSchedule": "预测下次运行时间",
|
|
88
|
+
"form.previewScheduleTitle": "未来 5 次计划运行时间:",
|
|
89
|
+
"search.placeholder": "搜索定时任务",
|
|
90
|
+
"filters.title": "筛选",
|
|
91
|
+
"filters.type": "类型",
|
|
92
|
+
"filters.model": "模型",
|
|
93
|
+
"filters.channel": "渠道",
|
|
94
|
+
"filters.any": "全部",
|
|
95
|
+
"filters.reset": "重置筛选",
|
|
96
|
+
"filters.empty": "没有匹配当前筛选条件的任务。",
|
|
97
|
+
"filters.showing": "显示 {shown} / 共 {total}",
|
|
98
|
+
"stats.activeTasks": "活跃任务",
|
|
99
|
+
"stats.totalRuns": "总运行次数",
|
|
100
|
+
"stats.totalTokens": "消耗 Token",
|
|
101
|
+
"stats.totalCost": "预估成本",
|
|
102
|
+
"tabs.all": "全部",
|
|
103
|
+
"tabs.active": "运行中",
|
|
104
|
+
"tabs.paused": "已暂停",
|
|
105
|
+
"tabs.completed": "已完成",
|
|
106
|
+
"list.empty": "暂无定时任务",
|
|
107
|
+
"list.emptyHint": "点击下方按钮让 DSH 规划任务,或手动配置。",
|
|
108
|
+
"list.oneShot": "一次性",
|
|
109
|
+
"list.runCount": "{count} 次运行",
|
|
110
|
+
"list.consecutiveFailures": "连续 {count} 次失败",
|
|
111
|
+
"list.lastRun": "上次: {time}",
|
|
112
|
+
"list.nextRun": "下次: {time}",
|
|
113
|
+
"list.badgeActive": "活跃",
|
|
114
|
+
"list.badgePaused": "已暂停",
|
|
115
|
+
"list.badgeCompleted": "已完成",
|
|
116
|
+
"list.configManaged": "配置只读",
|
|
117
|
+
"channel.telegram": "Telegram",
|
|
118
|
+
"channel.discord": "Discord",
|
|
119
|
+
"channel.slack": "Slack",
|
|
120
|
+
"channel.ntfy": "ntfy",
|
|
121
|
+
"channel.bark": "Bark",
|
|
122
|
+
"channel.pushplus": "PushPlus",
|
|
123
|
+
"channel.voice": "语音通话",
|
|
124
|
+
"channel.gitea": "Gitea",
|
|
125
|
+
"channel.webhook": "Webhook",
|
|
126
|
+
"modal.editTitle": "编辑任务",
|
|
127
|
+
"modal.newTitle": "新建定时任务",
|
|
128
|
+
"modal.statusActive": "活跃",
|
|
129
|
+
"modal.statusPaused": "已暂停",
|
|
130
|
+
"modal.params": "配置参数",
|
|
131
|
+
"modal.history": "运行历史 ({count})",
|
|
132
|
+
"modal.cancel": "取消",
|
|
133
|
+
"modal.confirmDiscard": "放弃未保存的内容?",
|
|
134
|
+
"modal.save": "保存修改",
|
|
135
|
+
"modal.create": "创建任务",
|
|
136
|
+
"history.loading": "正在加载历史记录...",
|
|
137
|
+
"history.empty": "暂无运行记录",
|
|
138
|
+
"history.summaryTokens": "消耗 Token: {count}",
|
|
139
|
+
"history.summaryCost": "预估成本: ${cost}",
|
|
140
|
+
"history.errorLabel": "错误详情:",
|
|
141
|
+
"history.outputLabel": "执行输出:",
|
|
142
|
+
"history.statusUncertain": "不确定",
|
|
143
|
+
"history.artifacts": "产物文件",
|
|
144
|
+
"history.resolveCompleted": "标记已完成",
|
|
145
|
+
"history.resolveFailed": "标记失败",
|
|
146
|
+
"history.statusUncertainHint": "服务重启中断 — 请确认结果",
|
|
147
|
+
"form.titleLabel": "任务名称 *",
|
|
148
|
+
"form.titlePlaceholder": "例如: 每周备份检查",
|
|
149
|
+
"form.typeLabel": "执行类型 *",
|
|
150
|
+
"form.promptLabel": "提示词内容 *",
|
|
151
|
+
"form.promptPlaceholder": "输入发送给智能体的执行指令...",
|
|
152
|
+
"form.modelLabel": "AI 模型",
|
|
153
|
+
"form.modelDefault": "默认模型",
|
|
154
|
+
"form.fallbackModelLabel": "备用模型 (主模型失败时使用)",
|
|
155
|
+
"form.fallbackModelNone": "不使用备用模型",
|
|
156
|
+
"form.pipelineGroupLabel": "任务流水线串联",
|
|
157
|
+
"form.onSuccessLabel": "成功后触发 (任务 ID)",
|
|
158
|
+
"form.onSuccessPlaceholder": "例如: task_upload_backup",
|
|
159
|
+
"form.onFailureLabel": "失败后触发 (任务 ID)",
|
|
160
|
+
"form.onFailurePlaceholder": "例如: task_notify_incident",
|
|
161
|
+
"form.scheduleLabel": "调度计划 (Cron 或相对时间) *",
|
|
162
|
+
"form.schedulePlaceholder": "例如: 0 9 * * 1-5 或 15m 或 in 2 hours",
|
|
163
|
+
"form.channelsLabel": "通知渠道",
|
|
164
|
+
"form.channelsHint": "任务执行完毕后推送报告的目标渠道。",
|
|
165
|
+
"form.templateLabel": "自定义消息模板",
|
|
166
|
+
"form.templatePlaceholder": "留空使用默认摘要",
|
|
167
|
+
"form.templateHint": "支持变量: {title}, {status}, {output}, {error}, {durationMs}, {time}",
|
|
168
|
+
"form.silentRuleLabel": "静默规则 (可选)",
|
|
169
|
+
"form.silentRulePlaceholder": "例如: ALL_OK 或 status == 200",
|
|
170
|
+
"form.silentRuleHint": "若输出符合此规则,将不发送任何通知。",
|
|
171
|
+
"form.advancedToggle": "高级执行选项",
|
|
172
|
+
"form.timezoneLabel": "时区 (IANA 时区名)",
|
|
173
|
+
"form.timezonePlaceholder": "例如: Asia/Shanghai 或 UTC (留空使用服务器时区)",
|
|
174
|
+
"form.misfireLabel": "错过执行策略 (Misfire Policy)",
|
|
175
|
+
"form.misfireSkip": "跳过 (默认)",
|
|
176
|
+
"form.misfireRunOnce": "补跑一次",
|
|
177
|
+
"form.maxRetriesLabel": "失败重试次数 (0 = 不重试)",
|
|
178
|
+
"form.permissionPresetLabel": "权限预设",
|
|
179
|
+
"form.permissionDefault": "默认 (继承主配置)",
|
|
180
|
+
"form.permissionRestricted": "受限模式 (只读)",
|
|
181
|
+
"form.permissionFull": "完全访问权限",
|
|
182
|
+
"form.envLabel": "环境变量 (KEY=VALUE, 每行一个)",
|
|
183
|
+
"form.envPlaceholder": "FOO=bar\\nAPI_KEY=secret",
|
|
184
|
+
"form.cwdLabel": "工作目录 (绝对路径)",
|
|
185
|
+
"form.cwdPlaceholder": "/var/www/my-project",
|
|
186
|
+
"form.workspaceIdLabel": "关联 DSH Workspace ID",
|
|
187
|
+
"form.workspaceIdPlaceholder": "留空使用默认工作区",
|
|
188
|
+
"form.worktreeLabel": "在独立的临时 Git Worktree 中执行",
|
|
189
|
+
"form.keepWorktreeLabel": "执行结束后保留 Git Worktree (不自动清理)",
|
|
190
|
+
"form.timeoutLabel": "超时时间 (秒)",
|
|
191
|
+
"form.overlapLabel": "并发策略",
|
|
192
|
+
"form.overlapSkip": "跳过 (默认)",
|
|
193
|
+
"form.overlapQueue": "排队等待",
|
|
194
|
+
"form.overlapTerminate": "终止前次运行",
|
|
195
|
+
"form.overlapAllow": "允许并发",
|
|
196
|
+
"form.kanbanLabel": "看板同步模式",
|
|
197
|
+
"form.kanbanNone": "不同步到看板",
|
|
198
|
+
"form.kanbanCreate": "仅创建新卡片",
|
|
199
|
+
"form.kanbanSync": "保持卡片同步更新",
|
|
200
|
+
"settings.deliveryChannels": "推送渠道配置",
|
|
201
|
+
"settings.credentialsHint": "安全凭据在 DSH Credentials 服务中统一管理。",
|
|
202
|
+
"settings.saveChannels": "保存渠道配置",
|
|
203
|
+
"settings.testing": "正在测试...",
|
|
204
|
+
"settings.testSuccess": "✅ 测试成功!",
|
|
205
|
+
"settings.automationHint": "用于静默规则研判和故障诊断的辅助模型。留空沿用任务自身模型。",
|
|
206
|
+
"settings.silentRuleModelLabel": "静默规则研判模型",
|
|
207
|
+
"settings.secretsOnCard": "设置中引用的凭据将在前端脱敏展示,并通过名称引用。",
|
|
208
|
+
"form.nextRunLabel": "下次执行: ",
|
|
209
|
+
"form.notScheduled": "未安排计划",
|
|
210
|
+
"settings.loading": "正在加载设置...",
|
|
211
|
+
"settings.unavailable": "设置当前不可用。",
|
|
212
|
+
"settings.retry": "重试",
|
|
213
|
+
"settings.cardTitle": "⏰ 定时任务调度器 (Cron)",
|
|
214
|
+
"settings.cardDesc": "定时计划任务(Cron/间隔)、后台智能体场景以及支持 Telegram、Discord、Slack、ntfy、Bark、PushPlus、语音和 Gitea 的通知推送。",
|
|
215
|
+
"settings.openPanel": "打开任务管理看板",
|
|
216
|
+
"settings.modalTitle": "通知与渠道设置",
|
|
217
|
+
"settings.tokenLabel": "Telegram Bot Token",
|
|
218
|
+
"settings.tokenPlaceholder": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
|
|
219
|
+
"settings.tokenPlaceholderDefault": "使用 DSH 全局配置的机器人 (或在此输入自定义 Token)",
|
|
220
|
+
"settings.defaultHint": "✓ 检测到 DSH 环境已配置默认 Bot",
|
|
221
|
+
"settings.chatIdLabel": "Telegram Chat ID",
|
|
222
|
+
"settings.chatIdPlaceholder": "例如: 345678901",
|
|
223
|
+
"settings.kanbanUrlLabel": "Kanban 服务基础地址",
|
|
224
|
+
"settings.globalNotify": "全局开启所有任务结果推送至 Telegram",
|
|
225
|
+
"settings.globalOnlyFailure": "全局模式:仅在失败时推送 (成功静默)",
|
|
226
|
+
"settings.dynamicCredentials": "动态凭据解析 (credential://...)",
|
|
227
|
+
"settings.dynamicCredentialsHint": "运行时从凭据服务动态解析 credential://... 且不泄露到存储 (#335)",
|
|
228
|
+
"settings.testTelegram": "🔔 测试 Telegram 发送",
|
|
229
|
+
"settings.testKanban": "📋 测试 Kanban",
|
|
230
|
+
"settings.sending": "正在发送...",
|
|
231
|
+
"settings.checkingKanban": "正在连接 Kanban...",
|
|
232
|
+
"settings.testOk": "✅ 测试消息已成功发送至 Telegram!",
|
|
233
|
+
"settings.testFail": "❌ 发送失败: {error}",
|
|
234
|
+
"settings.kanbanOk": "✅ Kanban 卡片创建成功",
|
|
235
|
+
"settings.kanbanFail": "❌ {error}",
|
|
236
|
+
"settings.save": "保存设置",
|
|
237
|
+
"settings.saved": "✓ 已保存",
|
|
238
|
+
"settings.saveError": "保存失败: {error}",
|
|
239
|
+
"settings.saveErrorUnknown": "未知错误",
|
|
240
|
+
"updater.title": "插件自更新",
|
|
241
|
+
"updater.btnCheck": "检查更新",
|
|
242
|
+
"updater.checking": "正在检查...",
|
|
243
|
+
"updater.btnUpdate": "升级至 v{version}",
|
|
244
|
+
"updater.updating": "正在更新...",
|
|
245
|
+
"updater.desc": "通过 npm 仓库在线检查并就地升级插件,无需 SSH 终端操作。",
|
|
246
|
+
"updater.current": "当前版本: v{version}",
|
|
247
|
+
"updater.available": "发现新版本: v{version}",
|
|
248
|
+
"updater.upToDate": "已是最新版本",
|
|
249
|
+
"updater.success": "已成功升级至 v{version}。请重启 DSH 或重载配置以应用更新。",
|
|
250
|
+
"dsh.modalTitle": "让 DSH 智能体创建任务",
|
|
251
|
+
"dsh.descLabel": "请描述你的任务目标和执行频率:",
|
|
252
|
+
"dsh.placeholder": "例如: 每两小时检查一次磁盘空间和失败的 systemd 守护进程,并在异常时报警...",
|
|
253
|
+
"dsh.sendHint": "按 Ctrl+Enter 发送",
|
|
254
|
+
"dsh.sendTitle": "提交给智能体",
|
|
255
|
+
"dsh.startFailed": "启动智能体对话失败: {error}",
|
|
256
|
+
"dsh.launchError": "执行错误: {error}",
|
|
257
|
+
"confirm.delete": "确定删除该任务吗?",
|
|
258
|
+
"form.requireApprovalLabel": "执行前需人工确认审批",
|
|
259
|
+
"form.requireApprovalHint": "开启后,任务执行前将挂起并等待授权用户批准提议 (#318)。",
|
|
260
|
+
"actions.approve": "批准",
|
|
261
|
+
"actions.reject": "拒绝",
|
|
262
|
+
"approval.waiting": "等待人工审批",
|
|
263
|
+
"approval.approved": "已批准",
|
|
264
|
+
"approval.rejected": "已拒绝",
|
|
265
|
+
"chainGraph.modalTitle": "任务链与检查点依赖图",
|
|
266
|
+
"chainGraph.viewGraph": "任务链图",
|
|
267
|
+
"chainGraph.noNodes": "未找到该任务或运行的关联步骤。",
|
|
268
|
+
"chainGraph.retryStep": "重试此步骤",
|
|
269
|
+
"chainGraph.stepSuccess": "成功",
|
|
270
|
+
"chainGraph.stepFailed": "失败",
|
|
271
|
+
"chainGraph.stepPending": "等待中",
|
|
272
|
+
"chainGraph.close": "关闭",
|
|
273
|
+
"chainGraph.decisionNode": "条件决策节点",
|
|
274
|
+
"chainGraph.stepSkipped": "已跳过",
|
|
275
|
+
"history.doctorDiagnosis": "Doctor 诊断与建议修复",
|
|
276
|
+
"history.doctorApply": "一键应用修复",
|
|
277
|
+
"history.doctorRevert": "恢复上一版本代码",
|
|
278
|
+
"history.autoHealed": "已由 Doctor 自动修复",
|
|
279
|
+
"form.autoHealLabel": "自主 Self-Healing Doctor 自动修复",
|
|
280
|
+
"form.autoHealHint": "当任务失败时自动诊断并安全修复(每天上限 1 次)",
|
|
281
|
+
"form.elseTaskLabel": "默认后备任务 (elseTask)",
|
|
282
|
+
"form.elseTaskHint": "当没有分支条件匹配时触发的目标任务",
|
|
283
|
+
"form.elseTaskPlaceholder": "如 task-fallback",
|
|
284
|
+
"adaptive.badge": "自适应",
|
|
285
|
+
"adaptive.idle": "空闲",
|
|
286
|
+
"adaptive.backoff": "避让中",
|
|
287
|
+
"form.adaptiveEnabled": "自适应轮询 (Adaptive Polling)",
|
|
288
|
+
"form.adaptiveHint": "数据无变化时指数级延长执行间隔",
|
|
289
|
+
"liveLog.modalTitle": "实时任务执行日志流",
|
|
290
|
+
"liveLog.viewLog": "实时日志",
|
|
291
|
+
"liveLog.autoScroll": "自动滚动",
|
|
292
|
+
"liveLog.connecting": "正在连接日志流...",
|
|
293
|
+
"liveLog.waiting": "等待运行器输出...",
|
|
294
|
+
"liveLog.completed": "日志流已结束",
|
|
295
|
+
"liveLog.close": "关闭日志流",
|
|
296
|
+
"timeline.modalTitle": "24小时调度时间线",
|
|
297
|
+
"timeline.buttonLabel": "时间线",
|
|
298
|
+
"timeline.buttonTitle": "查看24小时预测任务调度与并发时间线",
|
|
299
|
+
"timeline.tabTimeline": "调度时间线",
|
|
300
|
+
"timeline.legendDesc": "未来24小时各时段任务预计执行分布:",
|
|
301
|
+
"timeline.peakWarning": "并发峰值预警",
|
|
302
|
+
"heatmap.modalTitle": "成本与Token消耗热力图",
|
|
303
|
+
"heatmap.buttonLabel": "热力图",
|
|
304
|
+
"heatmap.tabHeatmap": "成本热力图",
|
|
305
|
+
"heatmap.matrixDesc": "每周各时段成本分布矩阵(7天 × 24小时):",
|
|
306
|
+
"digest.modalTitle": "自动化与成本分析摘要",
|
|
307
|
+
"digest.buttonLabel": "分析摘要",
|
|
308
|
+
"digest.buttonTitle": "查看 7 天或 30 天运行统计与花费摘要",
|
|
309
|
+
"digest.period7": "最近 7 天",
|
|
310
|
+
"digest.period30": "最近 30 天",
|
|
311
|
+
"digest.executions": "执行次数",
|
|
312
|
+
"digest.tokens": "Token 消耗",
|
|
313
|
+
"digest.cost": "花费 (USD)",
|
|
314
|
+
"digest.fleet": "任务总览",
|
|
315
|
+
"digest.unstableTitle": "最不稳定任务排行",
|
|
316
|
+
"digest.expensiveTitle": "资源消耗排行",
|
|
317
|
+
"digest.sendTelegram": "发送至 Telegram",
|
|
318
|
+
"digest.copyMarkdown": "复制 Markdown",
|
|
319
|
+
"digest.copied": "已复制!",
|
|
320
|
+
"digest.sentSuccess": "已成功发送至 Telegram!",
|
|
321
|
+
"prompt.version": "提示词版本",
|
|
322
|
+
"prompt.rollback": "回退至 v",
|
|
323
|
+
"prompt.history": "提示词历史",
|
|
324
|
+
"prompt.successRate": "成功率",
|
|
325
|
+
"form.chainingTitle": "任务链与 DAG 编排",
|
|
326
|
+
"form.onSuccessHint": "成功后并发触发的任务 ID,逗号分隔 (Fan-Out)",
|
|
327
|
+
"form.onFailureHint": "失败后触发的任务 ID,逗号分隔",
|
|
328
|
+
"form.dependsOnLabel": "依赖任务 (dependsOn)",
|
|
329
|
+
"form.dependsOnHint": "执行前需等待的前置任务 ID,逗号分隔 (Fan-In)",
|
|
330
|
+
"form.joinPolicyLabel": "合并策略",
|
|
331
|
+
"form.joinPolicyAll": "所有前置均成功 (all_success)",
|
|
332
|
+
"form.joinPolicyAny": "任一前置成功即可 (any_success)",
|
|
333
|
+
"recipe.dependenciesMissing": "缺少必要集成服务或凭据",
|
|
334
|
+
"list.loadErrorPrefix": "加载任务列表失败"
|
|
335
|
+
}
|
|
336
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
1
2
|
import { handleTaskWebhookRoute } from './api-task-webhook.js';
|
|
2
3
|
import { calculateCostHeatmap } from './timeline.js';
|
|
3
4
|
import { buildAnalyticsDigest, formatDigestMarkdown, sendAnalyticsDigest } from './analytics-digest.js';
|
|
@@ -15,7 +16,48 @@ import { createKanbanCard } from './integrations.js';
|
|
|
15
16
|
import { sanitizeSettingsPayload, applySettingsToScope } from './settings.js';
|
|
16
17
|
import { buildTaskGraphModel } from './dag.js';
|
|
17
18
|
|
|
19
|
+
|
|
20
|
+
const CLIENT_CSS = readFileSync(new URL('./client.css', import.meta.url), 'utf8');
|
|
21
|
+
const LOCALES_JSON = readFileSync(new URL('./locales.json', import.meta.url), 'utf8');
|
|
22
|
+
|
|
23
|
+
|
|
18
24
|
export function registerRoutes(ctx, { store, scheduler, recommendations }) {
|
|
25
|
+
// Client locales route (#374)
|
|
26
|
+
ctx.effect(() => ctx.webServer.register({
|
|
27
|
+
kind: 'exact',
|
|
28
|
+
path: '/dsh-cron/locales.json',
|
|
29
|
+
handler: (req, res) => {
|
|
30
|
+
if (req.method !== 'GET') {
|
|
31
|
+
res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
|
|
32
|
+
res.end('GET only');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
res.writeHead(200, {
|
|
36
|
+
'content-type': 'application/json; charset=utf-8',
|
|
37
|
+
'cache-control': 'public, max-age=300'
|
|
38
|
+
});
|
|
39
|
+
res.end(LOCALES_JSON);
|
|
40
|
+
}
|
|
41
|
+
}), 'dsh-cron: /locales.json');
|
|
42
|
+
|
|
43
|
+
// Client CSS stylesheet route (#374)
|
|
44
|
+
ctx.effect(() => ctx.webServer.register({
|
|
45
|
+
kind: 'exact',
|
|
46
|
+
path: '/dsh-cron/client.css',
|
|
47
|
+
handler: (req, res) => {
|
|
48
|
+
if (req.method !== 'GET') {
|
|
49
|
+
res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' });
|
|
50
|
+
res.end('GET only');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
res.writeHead(200, {
|
|
54
|
+
'content-type': 'text/css; charset=utf-8',
|
|
55
|
+
'cache-control': 'public, max-age=300'
|
|
56
|
+
});
|
|
57
|
+
res.end(CLIENT_CSS);
|
|
58
|
+
}
|
|
59
|
+
}), 'dsh-cron: /client.css');
|
|
60
|
+
|
|
19
61
|
// handler per route family; registered WITHOUT the trailing slash (#96)
|
|
20
62
|
ctx.effect(() => ctx.webServer.register({
|
|
21
63
|
kind: 'prefix',
|
package/lib/secrets.js
CHANGED
|
@@ -147,6 +147,7 @@ export function parseCredentialUri(value) {
|
|
|
147
147
|
*/
|
|
148
148
|
export async function resolveCredentialReference(uri, ctx) {
|
|
149
149
|
if (typeof uri !== 'string') return uri;
|
|
150
|
+
if (!isCredentialUri(uri)) return uri;
|
|
150
151
|
const parsed = parseCredentialUri(uri);
|
|
151
152
|
if (!parsed) return uri;
|
|
152
153
|
|
package/lib/store.js
CHANGED
|
@@ -213,12 +213,45 @@ export class TaskStore {
|
|
|
213
213
|
* Asynchronously serialize and write tasks store to disk using fs.promises queue (#244, #301, #303).
|
|
214
214
|
* Safe mode 0600 on files and 0700 on dir (#222).
|
|
215
215
|
*/
|
|
216
|
+
/**
|
|
217
|
+
* Wait until diskVersion or lastSyncVersion is at least the target version (#372).
|
|
218
|
+
*/
|
|
219
|
+
_waitForDiskVersion(version) {
|
|
220
|
+
if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version) {
|
|
221
|
+
return Promise.resolve();
|
|
222
|
+
}
|
|
223
|
+
return new Promise((resolve, reject) => {
|
|
224
|
+
if (!this._diskWaiters) this._diskWaiters = [];
|
|
225
|
+
this._diskWaiters.push({ version, resolve, reject });
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_notifyDiskVersion(writtenVersion, err = null) {
|
|
230
|
+
if (!this._diskWaiters || this._diskWaiters.length === 0) return;
|
|
231
|
+
const remaining = [];
|
|
232
|
+
for (const waiter of this._diskWaiters) {
|
|
233
|
+
if (err) {
|
|
234
|
+
waiter.reject(err);
|
|
235
|
+
} else if ((this._diskVersion || 0) >= waiter.version || (this._lastSyncVersion || 0) >= waiter.version) {
|
|
236
|
+
waiter.resolve();
|
|
237
|
+
} else {
|
|
238
|
+
remaining.push(waiter);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
this._diskWaiters = remaining;
|
|
242
|
+
}
|
|
243
|
+
|
|
216
244
|
async saveAsync() {
|
|
245
|
+
if (this._saveTimer) {
|
|
246
|
+
clearTimeout(this._saveTimer);
|
|
247
|
+
this._saveTimer = null;
|
|
248
|
+
}
|
|
217
249
|
const version = ++this._saveVersion;
|
|
218
250
|
const data = this._createSnapshot();
|
|
219
251
|
|
|
220
252
|
const writeOp = async () => {
|
|
221
253
|
if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version) {
|
|
254
|
+
this._notifyDiskVersion(version);
|
|
222
255
|
return;
|
|
223
256
|
}
|
|
224
257
|
const dir = path.dirname(this.filePath);
|
|
@@ -238,12 +271,14 @@ export class TaskStore {
|
|
|
238
271
|
} catch (writeErr) {
|
|
239
272
|
await fs.promises.unlink(tmp).catch(() => {});
|
|
240
273
|
if (this._currentAsyncTmp === tmp) this._currentAsyncTmp = null;
|
|
274
|
+
this._notifyDiskVersion(null, writeErr);
|
|
241
275
|
throw writeErr;
|
|
242
276
|
}
|
|
243
277
|
|
|
244
278
|
if ((this._diskVersion || 0) >= version || (this._lastSyncVersion || 0) >= version || this._saveVersion > version) {
|
|
245
279
|
await fs.promises.unlink(tmp).catch(() => {});
|
|
246
280
|
if (this._currentAsyncTmp === tmp) this._currentAsyncTmp = null;
|
|
281
|
+
// Do not notify waiters yet: a newer queued saveOp will write to disk and notify (#372)
|
|
247
282
|
return;
|
|
248
283
|
}
|
|
249
284
|
|
|
@@ -251,14 +286,17 @@ export class TaskStore {
|
|
|
251
286
|
await fs.promises.rename(tmp, this.filePath);
|
|
252
287
|
bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
|
|
253
288
|
this._currentAsyncTmp = null;
|
|
254
|
-
this._diskVersion = version;
|
|
255
|
-
this._lastWrittenVersion =
|
|
289
|
+
this._diskVersion = Math.max(this._diskVersion || 0, version);
|
|
290
|
+
this._lastWrittenVersion = this._diskVersion;
|
|
291
|
+
this._notifyDiskVersion(this._diskVersion);
|
|
256
292
|
} catch (err) {
|
|
257
293
|
this._currentAsyncTmp = null;
|
|
258
294
|
// If tmp was unlinked by newer sync save, rename will fail with ENOENT - safely ignore only in that case (#244)
|
|
259
295
|
if (err.code === 'ENOENT' && (this._lastSyncVersion || 0) >= version) {
|
|
296
|
+
this._notifyDiskVersion(version);
|
|
260
297
|
return;
|
|
261
298
|
}
|
|
299
|
+
this._notifyDiskVersion(null, err);
|
|
262
300
|
throw err;
|
|
263
301
|
}
|
|
264
302
|
|
|
@@ -276,7 +314,8 @@ export class TaskStore {
|
|
|
276
314
|
};
|
|
277
315
|
|
|
278
316
|
this._savePromise = this._savePromise.then(writeOp, writeOp);
|
|
279
|
-
|
|
317
|
+
await this._savePromise;
|
|
318
|
+
await this._waitForDiskVersion(version);
|
|
280
319
|
}
|
|
281
320
|
|
|
282
321
|
/**
|
|
@@ -410,6 +449,7 @@ export class TaskStore {
|
|
|
410
449
|
bestEffort('chmod-store', () => fs.chmodSync(this.filePath, 0o600));
|
|
411
450
|
this._diskVersion = Math.max(this._diskVersion || 0, version);
|
|
412
451
|
this._lastWrittenVersion = this._diskVersion;
|
|
452
|
+
this._notifyDiskVersion(this._diskVersion);
|
|
413
453
|
|
|
414
454
|
// Throttled backup (.bak): avoid expensive copyFileSync on every micro-save
|
|
415
455
|
const now = Date.now();
|