@wanghaopeng1148/deskpet 2.0.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 +394 -0
- package/bin/deskpet.mjs +142 -0
- package/dist/web/assets/DashboardView-BLXa7VrZ.css +1 -0
- package/dist/web/assets/DashboardView-CTyWcLtQ.js +60 -0
- package/dist/web/assets/SettingsView-7c3RiRrt.js +1 -0
- package/dist/web/assets/SettingsView-BkL0OpZC.css +1 -0
- package/dist/web/assets/TasksView-BTK1OTwU.css +1 -0
- package/dist/web/assets/TasksView-s1MaR5sZ.js +5 -0
- package/dist/web/assets/ToolsView-B8V-A1j_.js +178 -0
- package/dist/web/assets/ToolsView-DGLJATQ9.css +1 -0
- package/dist/web/assets/WeChatView-ChLBhPso.js +1 -0
- package/dist/web/assets/WeChatView-CvdhJ05E.css +1 -0
- package/dist/web/assets/browser-CjSdxGTc.js +8 -0
- package/dist/web/assets/dashboard-DJ_Miuzx.js +93 -0
- package/dist/web/assets/dashboard-Fbcagzwx.css +1 -0
- package/dist/web/dashboard/index.html +13 -0
- package/package.json +71 -0
- package/resources/icons/tray.png +0 -0
- package/resources/icons/tray@2x.png +0 -0
- package/resources/previews/busy.png +0 -0
- package/resources/previews/click.png +0 -0
- package/resources/previews/hover.png +0 -0
- package/resources/previews/idle.png +0 -0
- package/resources/previews/preview.png +0 -0
- package/resources/previews/sleep.png +0 -0
- package/resources/skins/default_cute/pet.json +7 -0
- package/resources/skins/default_cute/spritesheet.webp +0 -0
- package/resources/skins/default_cute/submission.json +29 -0
- package/server/db/database.ts +118 -0
- package/server/db/migrate-legacy.ts +121 -0
- package/server/db/task-repository.ts +585 -0
- package/server/http/http-server.ts +725 -0
- package/server/http/ws-hub.ts +66 -0
- package/server/main.ts +322 -0
- package/server/plugins/actions/builtin.ts +73 -0
- package/server/plugins/actions/clipboard-watch.ts +38 -0
- package/server/plugins/actions/http-request.ts +53 -0
- package/server/plugins/actions/jenkins-build.ts +209 -0
- package/server/plugins/actions/open-app.ts +40 -0
- package/server/plugins/actions/python-script.ts +187 -0
- package/server/plugins/actions/screenshot.ts +41 -0
- package/server/plugins/actions/send-keystroke.ts +103 -0
- package/server/plugins/actions/show-reminder.ts +16 -0
- package/server/plugins/actions/ssh-command.ts +148 -0
- package/server/plugins/actions/task-chain.ts +30 -0
- package/server/plugins/actions/volume-control.ts +35 -0
- package/server/plugins/index.ts +37 -0
- package/server/plugins/registry.ts +72 -0
- package/server/services/clipboard-watcher.ts +126 -0
- package/server/services/config-store.ts +152 -0
- package/server/services/idle-monitor.ts +146 -0
- package/server/services/notifier.ts +54 -0
- package/server/services/quick-actions-store.ts +54 -0
- package/server/services/remote-connector.ts +75 -0
- package/server/services/scanner-reader.ts +257 -0
- package/server/services/script-runner.ts +263 -0
- package/server/services/snapshot-service.ts +196 -0
- package/server/services/task-scheduler.ts +900 -0
- package/server/services/wechat-bot.ts +744 -0
- package/server/services/wechat-command-types.ts +15 -0
- package/server/services/wechat-commands.ts +367 -0
- package/server/suppress-warnings.ts +10 -0
- package/server/utils/asset-url.ts +27 -0
- package/server/utils/auto-start.ts +90 -0
- package/server/utils/clipboard.ts +50 -0
- package/server/utils/dashboard-url.ts +9 -0
- package/server/utils/instance-guard.ts +170 -0
- package/server/utils/native-notify.ts +68 -0
- package/server/utils/open.ts +38 -0
- package/server/utils/paths.ts +72 -0
- package/server/utils/python-interpreter.ts +154 -0
- package/shared/animation-engine.ts +422 -0
- package/shared/chain-condition.ts +60 -0
- package/shared/cron-weekly.ts +131 -0
- package/shared/py-task-params.ts +356 -0
- package/shared/types.ts +309 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务仓储 — tasks / executions 表的读写映射
|
|
3
|
+
*/
|
|
4
|
+
import type { DatabaseSync } from 'node:sqlite'
|
|
5
|
+
import type { ExecutionRecord, TaskConfig, TaskDto, TaskStatus, TaskTrigger } from '../../shared/types.ts'
|
|
6
|
+
import { TaskStatus as TS } from '../../shared/types.ts'
|
|
7
|
+
|
|
8
|
+
export interface TaskRow {
|
|
9
|
+
id: string
|
|
10
|
+
name: string
|
|
11
|
+
type: string
|
|
12
|
+
action: string
|
|
13
|
+
action_params: string
|
|
14
|
+
trigger_type: string
|
|
15
|
+
trigger_params: string
|
|
16
|
+
script_path: string
|
|
17
|
+
interpreter: string | null
|
|
18
|
+
script_args: string
|
|
19
|
+
script_timeout: number
|
|
20
|
+
work_dir: string | null
|
|
21
|
+
priority: number
|
|
22
|
+
retry_count: number
|
|
23
|
+
enabled: number
|
|
24
|
+
notify_channels: string
|
|
25
|
+
chain_next: string | null
|
|
26
|
+
chain_condition: string | null
|
|
27
|
+
one_time: number
|
|
28
|
+
status: string
|
|
29
|
+
execution_count: number
|
|
30
|
+
created_at: string
|
|
31
|
+
updated_at: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 每个任务保留的执行记录条数(超出即删除最旧的) */
|
|
35
|
+
export const EXECUTION_KEEP_PER_TASK = 10
|
|
36
|
+
|
|
37
|
+
/** updateExecution 允许更新的列(camelCase → snake_case 白名单) */
|
|
38
|
+
const EXEC_UPDATE_COLUMNS: Record<string, string> = { taskId: 'task_id',
|
|
39
|
+
taskName: 'task_name',
|
|
40
|
+
status: 'status',
|
|
41
|
+
startedAt: 'started_at',
|
|
42
|
+
finishedAt: 'finished_at',
|
|
43
|
+
exitCode: 'exit_code',
|
|
44
|
+
durationMs: 'duration_ms',
|
|
45
|
+
/** 子进程 pid(服务重启后用于判断脚本是否仍在后台运行) */
|
|
46
|
+
pid: 'pid',
|
|
47
|
+
stdout: 'stdout',
|
|
48
|
+
stderr: 'stderr',
|
|
49
|
+
error: 'error'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const TASK_COLUMNS =
|
|
53
|
+
'id, name, type, action, action_params, trigger_type, trigger_params, ' +
|
|
54
|
+
'script_path, interpreter, script_args, script_timeout, work_dir, priority, ' +
|
|
55
|
+
'retry_count, enabled, notify_channels, chain_next, chain_condition, one_time, ' +
|
|
56
|
+
'status, execution_count, created_at, updated_at'
|
|
57
|
+
|
|
58
|
+
function defaultTrigger(): TaskTrigger {
|
|
59
|
+
return {
|
|
60
|
+
type: 'manual',
|
|
61
|
+
datetime: null,
|
|
62
|
+
cron: null,
|
|
63
|
+
delaySeconds: null,
|
|
64
|
+
intervalMinutes: null,
|
|
65
|
+
idleMinutes: null,
|
|
66
|
+
maxExecutions: -1,
|
|
67
|
+
holidayCheck: false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function defaultTaskConfig(): TaskConfig {
|
|
72
|
+
return {
|
|
73
|
+
name: '',
|
|
74
|
+
type: 'manual',
|
|
75
|
+
trigger: defaultTrigger(),
|
|
76
|
+
action: 'python_script',
|
|
77
|
+
actionParams: {},
|
|
78
|
+
scriptPath: '',
|
|
79
|
+
interpreter: null,
|
|
80
|
+
scriptArgs: [],
|
|
81
|
+
scriptTimeout: 120,
|
|
82
|
+
scriptWorkDir: null,
|
|
83
|
+
retryCount: 0,
|
|
84
|
+
enabled: true,
|
|
85
|
+
notifyChannels: ['desktop'],
|
|
86
|
+
chainNext: null,
|
|
87
|
+
chainCondition: null,
|
|
88
|
+
oneTime: false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function taskConfigToRow(config: TaskConfig, now: string): Omit<TaskRow, 'id' | 'status' | 'execution_count' | 'created_at'> {
|
|
93
|
+
return {
|
|
94
|
+
name: config.name,
|
|
95
|
+
type: config.type,
|
|
96
|
+
action: config.action,
|
|
97
|
+
action_params: JSON.stringify(config.actionParams ?? {}),
|
|
98
|
+
trigger_type: config.trigger?.type ?? 'manual',
|
|
99
|
+
trigger_params: JSON.stringify(config.trigger ?? defaultTrigger()),
|
|
100
|
+
script_path: config.scriptPath ?? '',
|
|
101
|
+
interpreter: config.interpreter ?? null,
|
|
102
|
+
script_args: JSON.stringify(config.scriptArgs ?? []),
|
|
103
|
+
script_timeout: config.scriptTimeout ?? 120,
|
|
104
|
+
work_dir: config.scriptWorkDir ?? null,
|
|
105
|
+
// priority 列已废弃(不再可配置),保留常量以兼容既有数据库表结构
|
|
106
|
+
priority: 1,
|
|
107
|
+
retry_count: config.retryCount ?? 0,
|
|
108
|
+
enabled: config.enabled ? 1 : 0,
|
|
109
|
+
notify_channels: JSON.stringify(config.notifyChannels ?? ['desktop']),
|
|
110
|
+
chain_next: config.chainNext ?? null,
|
|
111
|
+
chain_condition: config.chainCondition ?? null,
|
|
112
|
+
one_time: config.oneTime ? 1 : 0,
|
|
113
|
+
updated_at: now
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function rowToTaskConfig(row: TaskRow): TaskConfig {
|
|
118
|
+
let trigger: TaskTrigger = defaultTrigger()
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(row.trigger_params ?? '{}')
|
|
121
|
+
trigger = { ...defaultTrigger(), ...parsed, type: row.trigger_type || parsed.type || 'manual' }
|
|
122
|
+
} catch {
|
|
123
|
+
trigger.type = row.trigger_type || 'manual'
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
name: row.name,
|
|
127
|
+
type: row.type as TaskConfig['type'],
|
|
128
|
+
trigger,
|
|
129
|
+
action: row.action,
|
|
130
|
+
actionParams: safeJson(row.action_params, {}),
|
|
131
|
+
scriptPath: row.script_path ?? '',
|
|
132
|
+
interpreter: row.interpreter ?? null,
|
|
133
|
+
scriptArgs: safeJson(row.script_args, []),
|
|
134
|
+
scriptTimeout: row.script_timeout ?? 120,
|
|
135
|
+
scriptWorkDir: row.work_dir ?? null,
|
|
136
|
+
retryCount: row.retry_count ?? 0,
|
|
137
|
+
enabled: row.enabled === 1,
|
|
138
|
+
notifyChannels: safeJson(row.notify_channels, ['desktop']),
|
|
139
|
+
chainNext: row.chain_next ?? null,
|
|
140
|
+
chainCondition: row.chain_condition ?? null,
|
|
141
|
+
oneTime: row.one_time === 1
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function rowToTaskDto(row: TaskRow): TaskDto {
|
|
146
|
+
return {
|
|
147
|
+
id: row.id,
|
|
148
|
+
config: rowToTaskConfig(row),
|
|
149
|
+
status: row.status as TaskStatus,
|
|
150
|
+
createdAt: row.created_at,
|
|
151
|
+
executionCount: row.execution_count ?? 0,
|
|
152
|
+
lastRunAt: null, // 由调度器内存态补充
|
|
153
|
+
lastResult: null,
|
|
154
|
+
lastError: null,
|
|
155
|
+
nextRunAt: null,
|
|
156
|
+
currentRetry: 0
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function safeJson<T>(s: string | null | undefined, fallback: T): T {
|
|
161
|
+
if (!s) return fallback
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(s) as T
|
|
164
|
+
} catch {
|
|
165
|
+
return fallback
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export class TaskRepository {
|
|
170
|
+
private db: DatabaseSync
|
|
171
|
+
|
|
172
|
+
constructor(db: DatabaseSync) {
|
|
173
|
+
this.db = db
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── tasks ────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
listAll(): TaskDto[] {
|
|
179
|
+
const rows = this.db
|
|
180
|
+
.prepare(`SELECT ${TASK_COLUMNS} FROM tasks ORDER BY created_at`)
|
|
181
|
+
.all() as unknown as TaskRow[]
|
|
182
|
+
return rows.map(rowToTaskDto)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
get(id: string): TaskDto | null {
|
|
186
|
+
const row = this.db.prepare(`SELECT ${TASK_COLUMNS} FROM tasks WHERE id = ?`).get(id) as
|
|
187
|
+
| TaskRow
|
|
188
|
+
| undefined
|
|
189
|
+
return row ? rowToTaskDto(row) : null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
count(): number {
|
|
193
|
+
const row = this.db.prepare('SELECT COUNT(*) AS c FROM tasks').get() as { c: number }
|
|
194
|
+
return row.c
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
insert(dto: TaskDto): void {
|
|
198
|
+
const now = dto.createdAt
|
|
199
|
+
const r = taskConfigToRow(dto.config, now)
|
|
200
|
+
this.db
|
|
201
|
+
.prepare(
|
|
202
|
+
`INSERT INTO tasks (${TASK_COLUMNS}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
|
203
|
+
)
|
|
204
|
+
.run(
|
|
205
|
+
dto.id,
|
|
206
|
+
r.name,
|
|
207
|
+
r.type,
|
|
208
|
+
r.action,
|
|
209
|
+
r.action_params,
|
|
210
|
+
r.trigger_type,
|
|
211
|
+
r.trigger_params,
|
|
212
|
+
r.script_path,
|
|
213
|
+
r.interpreter,
|
|
214
|
+
r.script_args,
|
|
215
|
+
r.script_timeout,
|
|
216
|
+
r.work_dir,
|
|
217
|
+
r.priority,
|
|
218
|
+
r.retry_count,
|
|
219
|
+
r.enabled,
|
|
220
|
+
r.notify_channels,
|
|
221
|
+
r.chain_next,
|
|
222
|
+
r.chain_condition,
|
|
223
|
+
r.one_time,
|
|
224
|
+
dto.status,
|
|
225
|
+
dto.executionCount,
|
|
226
|
+
now,
|
|
227
|
+
r.updated_at
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
update(id: string, config: TaskConfig, status?: TaskStatus, executionCount?: number): boolean {
|
|
232
|
+
const r = taskConfigToRow(config, new Date().toISOString())
|
|
233
|
+
const statusVal = status ?? undefined
|
|
234
|
+
const execVal = executionCount ?? undefined
|
|
235
|
+
const result = this.db
|
|
236
|
+
.prepare(
|
|
237
|
+
`UPDATE tasks SET
|
|
238
|
+
name=?, type=?, action=?, action_params=?, trigger_type=?, trigger_params=?,
|
|
239
|
+
script_path=?, interpreter=?, script_args=?, script_timeout=?, work_dir=?,
|
|
240
|
+
priority=?, retry_count=?, enabled=?, notify_channels=?, chain_next=?,
|
|
241
|
+
chain_condition=?, one_time=?, status=COALESCE(?, status), execution_count=COALESCE(?, execution_count),
|
|
242
|
+
updated_at=?
|
|
243
|
+
WHERE id=?`
|
|
244
|
+
)
|
|
245
|
+
.run(
|
|
246
|
+
r.name,
|
|
247
|
+
r.type,
|
|
248
|
+
r.action,
|
|
249
|
+
r.action_params,
|
|
250
|
+
r.trigger_type,
|
|
251
|
+
r.trigger_params,
|
|
252
|
+
r.script_path,
|
|
253
|
+
r.interpreter,
|
|
254
|
+
r.script_args,
|
|
255
|
+
r.script_timeout,
|
|
256
|
+
r.work_dir,
|
|
257
|
+
r.priority,
|
|
258
|
+
r.retry_count,
|
|
259
|
+
r.enabled,
|
|
260
|
+
r.notify_channels,
|
|
261
|
+
r.chain_next,
|
|
262
|
+
r.chain_condition,
|
|
263
|
+
r.one_time,
|
|
264
|
+
statusVal ?? null,
|
|
265
|
+
execVal ?? null,
|
|
266
|
+
r.updated_at,
|
|
267
|
+
id
|
|
268
|
+
)
|
|
269
|
+
return result.changes > 0
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
delete(id: string): boolean {
|
|
273
|
+
return this.db.prepare('DELETE FROM tasks WHERE id = ?').run(id).changes > 0
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 整体替换任务集(快照恢复 / 配置包导入用) */
|
|
277
|
+
replaceAll(tasks: Array<{ id: string; config: TaskConfig }>): void {
|
|
278
|
+
const now = new Date().toISOString()
|
|
279
|
+
const insert = this.db.prepare(
|
|
280
|
+
`INSERT INTO tasks (${TASK_COLUMNS}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
|
281
|
+
)
|
|
282
|
+
this.db.exec('BEGIN')
|
|
283
|
+
try {
|
|
284
|
+
this.db.exec('DELETE FROM tasks')
|
|
285
|
+
for (const t of tasks) {
|
|
286
|
+
const r = taskConfigToRow(t.config, now)
|
|
287
|
+
insert.run(
|
|
288
|
+
t.id,
|
|
289
|
+
r.name,
|
|
290
|
+
r.type,
|
|
291
|
+
r.action,
|
|
292
|
+
r.action_params,
|
|
293
|
+
r.trigger_type,
|
|
294
|
+
r.trigger_params,
|
|
295
|
+
r.script_path,
|
|
296
|
+
r.interpreter,
|
|
297
|
+
r.script_args,
|
|
298
|
+
r.script_timeout,
|
|
299
|
+
r.work_dir,
|
|
300
|
+
r.priority,
|
|
301
|
+
r.retry_count,
|
|
302
|
+
r.enabled,
|
|
303
|
+
r.notify_channels,
|
|
304
|
+
r.chain_next,
|
|
305
|
+
r.chain_condition,
|
|
306
|
+
r.one_time,
|
|
307
|
+
'idle',
|
|
308
|
+
0,
|
|
309
|
+
now,
|
|
310
|
+
r.updated_at
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
this.db.exec('COMMIT')
|
|
314
|
+
} catch (err) {
|
|
315
|
+
this.db.exec('ROLLBACK')
|
|
316
|
+
throw err
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── executions ───────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
insertExecution(rec: Omit<ExecutionRecord, 'id'>): number {
|
|
323
|
+
const result = this.db
|
|
324
|
+
.prepare(
|
|
325
|
+
`INSERT INTO executions (task_id, task_name, status, started_at, finished_at, exit_code, duration_ms, pid, stdout, stderr, error)
|
|
326
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?)`
|
|
327
|
+
)
|
|
328
|
+
.run(
|
|
329
|
+
rec.taskId,
|
|
330
|
+
rec.taskName,
|
|
331
|
+
rec.status,
|
|
332
|
+
rec.startedAt,
|
|
333
|
+
rec.finishedAt,
|
|
334
|
+
rec.exitCode,
|
|
335
|
+
rec.durationMs,
|
|
336
|
+
rec.pid ?? null,
|
|
337
|
+
rec.stdout,
|
|
338
|
+
rec.stderr,
|
|
339
|
+
rec.error
|
|
340
|
+
)
|
|
341
|
+
const id = Number(result.lastInsertRowid)
|
|
342
|
+
// 只保留每个任务最近的 N 条执行记录(超出即删除最旧的)。
|
|
343
|
+
// 这里是执行热路径,裁剪失败绝不能影响任务本身 → 静默忽略错误。
|
|
344
|
+
try {
|
|
345
|
+
this.pruneExecutionsToLimit(rec.taskId)
|
|
346
|
+
} catch {
|
|
347
|
+
/* 清理失败可忽略(下次执行或启动时还会再清) */
|
|
348
|
+
}
|
|
349
|
+
return id
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
updateExecution(id: number, patch: Partial<Omit<ExecutionRecord, 'id'>>): void {
|
|
353
|
+
const sets: string[] = []
|
|
354
|
+
const values: (string | number | bigint | null)[] = []
|
|
355
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
356
|
+
const col = EXEC_UPDATE_COLUMNS[k]
|
|
357
|
+
if (!col) continue
|
|
358
|
+
sets.push(`${col} = ?`)
|
|
359
|
+
values.push(typeof v === 'boolean' ? (v ? 1 : 0) : (v ?? null))
|
|
360
|
+
}
|
|
361
|
+
if (sets.length === 0) return
|
|
362
|
+
values.push(id)
|
|
363
|
+
this.db.prepare(`UPDATE executions SET ${sets.join(', ')} WHERE id = ?`).run(...values)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
listExecutions(taskId?: string, limit = 100, offset = 0): ExecutionRecord[] {
|
|
367
|
+
const where = taskId ? 'WHERE task_id = ?' : ''
|
|
368
|
+
const params = taskId ? [taskId, limit, offset] : [limit, offset]
|
|
369
|
+
interface ExecRow {
|
|
370
|
+
id: number
|
|
371
|
+
task_id: string
|
|
372
|
+
task_name: string
|
|
373
|
+
status: string
|
|
374
|
+
started_at: string
|
|
375
|
+
finished_at: string | null
|
|
376
|
+
exit_code: number | null
|
|
377
|
+
duration_ms: number | null
|
|
378
|
+
pid: number | null
|
|
379
|
+
stdout: string
|
|
380
|
+
stderr: string
|
|
381
|
+
error: string
|
|
382
|
+
}
|
|
383
|
+
const rows = this.db
|
|
384
|
+
.prepare(
|
|
385
|
+
`SELECT id, task_id, task_name, status, started_at, finished_at, exit_code, duration_ms, pid, stdout, stderr, error
|
|
386
|
+
FROM executions ${where} ORDER BY started_at DESC LIMIT ? OFFSET ?`
|
|
387
|
+
)
|
|
388
|
+
.all(...params) as unknown as ExecRow[]
|
|
389
|
+
return rows.map(rowToExecution)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
getRecentExecutions(limit = 50): ExecutionRecord[] {
|
|
393
|
+
return this.listExecutions(undefined, limit, 0)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* 收尾「进程被强杀」遗留的 running 执行记录(启动时调用)
|
|
398
|
+
*
|
|
399
|
+
* 脚本执行中若进程被 Ctrl+C / 强杀,走不到 updateExecution,
|
|
400
|
+
* 记录会永远停在 running:历史里显示「运行中」、完成时间与耗时都是空。
|
|
401
|
+
* @returns 被收尾的记录条数
|
|
402
|
+
*/
|
|
403
|
+
markRunningExecutionsInterrupted(
|
|
404
|
+
reason = '服务重启,执行被中断',
|
|
405
|
+
now = new Date().toISOString(),
|
|
406
|
+
onlyIds?: number[]
|
|
407
|
+
): number {
|
|
408
|
+
// 明确指定「没有任何一条要处理」时直接返回,避免退化成全表收尾
|
|
409
|
+
if (onlyIds && onlyIds.length === 0) return 0
|
|
410
|
+
const scope = onlyIds?.length
|
|
411
|
+
? ` AND id IN (${onlyIds.map(() => '?').join(',')})`
|
|
412
|
+
: ''
|
|
413
|
+
const result = this.db
|
|
414
|
+
.prepare(
|
|
415
|
+
`UPDATE executions
|
|
416
|
+
SET status = 'failed',
|
|
417
|
+
finished_at = COALESCE(finished_at, ?),
|
|
418
|
+
duration_ms = COALESCE(
|
|
419
|
+
duration_ms,
|
|
420
|
+
(julianday(?) - julianday(started_at)) * 86400000
|
|
421
|
+
),
|
|
422
|
+
error = CASE
|
|
423
|
+
WHEN error IS NULL OR error = '' THEN ?
|
|
424
|
+
ELSE error || char(10) || ?
|
|
425
|
+
END
|
|
426
|
+
WHERE status = 'running'${scope}`
|
|
427
|
+
)
|
|
428
|
+
.run(now, now, reason, reason, ...(onlyIds ?? []))
|
|
429
|
+
return Number(result.changes ?? 0)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* 仍未结束(status='running')的执行记录,含子进程 pid。
|
|
434
|
+
*
|
|
435
|
+
* 启动恢复时用它区分两种情况:
|
|
436
|
+
* · pid 已不存在 → 进程真的没了,按「执行被中断」收尾
|
|
437
|
+
* · pid 还活着 → 脚本仍在后台跑(Windows 上父进程退出并不带走子进程),
|
|
438
|
+
* 必须如实标注,否则用户会以为失败了而重复触发部署
|
|
439
|
+
*/
|
|
440
|
+
listRunningExecutions(): Array<{
|
|
441
|
+
id: number
|
|
442
|
+
taskId: string
|
|
443
|
+
taskName: string
|
|
444
|
+
pid: number | null
|
|
445
|
+
startedAt: string
|
|
446
|
+
}> {
|
|
447
|
+
const rows = this.db
|
|
448
|
+
.prepare(
|
|
449
|
+
`SELECT id, task_id, task_name, pid, started_at
|
|
450
|
+
FROM executions WHERE status = 'running' ORDER BY id ASC`
|
|
451
|
+
)
|
|
452
|
+
.all() as unknown as Array<{
|
|
453
|
+
id: number
|
|
454
|
+
task_id: string
|
|
455
|
+
task_name: string
|
|
456
|
+
pid: number | null
|
|
457
|
+
started_at: string
|
|
458
|
+
}>
|
|
459
|
+
return rows.map((r) => ({
|
|
460
|
+
id: Number(r.id),
|
|
461
|
+
taskId: r.task_id,
|
|
462
|
+
taskName: r.task_name,
|
|
463
|
+
pid: r.pid == null ? null : Number(r.pid),
|
|
464
|
+
startedAt: r.started_at
|
|
465
|
+
}))
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* 按「每个任务只保留最近 N 条」清理执行记录
|
|
470
|
+
* @param taskId 只清理指定任务;不传则对所有任务各清理一遍(启动时用)
|
|
471
|
+
* @returns 删除的条数
|
|
472
|
+
*/
|
|
473
|
+
pruneExecutionsToLimit(taskId?: string, keep = EXECUTION_KEEP_PER_TASK): number {
|
|
474
|
+
const keepCount = Math.max(0, Math.floor(keep))
|
|
475
|
+
interface ExecIdRow {
|
|
476
|
+
id: number
|
|
477
|
+
task_id: string
|
|
478
|
+
}
|
|
479
|
+
const rows = (
|
|
480
|
+
taskId
|
|
481
|
+
? this.db
|
|
482
|
+
.prepare(
|
|
483
|
+
'SELECT id, task_id FROM executions WHERE task_id = ? ORDER BY started_at DESC, id DESC'
|
|
484
|
+
)
|
|
485
|
+
.all(taskId)
|
|
486
|
+
: this.db
|
|
487
|
+
.prepare(
|
|
488
|
+
'SELECT id, task_id FROM executions ORDER BY task_id ASC, started_at DESC, id DESC'
|
|
489
|
+
)
|
|
490
|
+
.all()
|
|
491
|
+
) as unknown as ExecIdRow[]
|
|
492
|
+
|
|
493
|
+
// 行已按「新 → 旧」排序,每个任务计数超过 keepCount 的都是要删的
|
|
494
|
+
const seen = new Map<string, number>()
|
|
495
|
+
const doomed: number[] = []
|
|
496
|
+
for (const r of rows) {
|
|
497
|
+
const n = (seen.get(r.task_id) ?? 0) + 1
|
|
498
|
+
seen.set(r.task_id, n)
|
|
499
|
+
if (n > keepCount) doomed.push(r.id)
|
|
500
|
+
}
|
|
501
|
+
if (doomed.length === 0) return 0
|
|
502
|
+
|
|
503
|
+
const del = this.db.prepare('DELETE FROM executions WHERE id = ?')
|
|
504
|
+
this.db.exec('BEGIN')
|
|
505
|
+
try {
|
|
506
|
+
for (const id of doomed) del.run(id)
|
|
507
|
+
this.db.exec('COMMIT')
|
|
508
|
+
} catch (err) {
|
|
509
|
+
this.db.exec('ROLLBACK')
|
|
510
|
+
throw err
|
|
511
|
+
}
|
|
512
|
+
return doomed.length
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ── 微信消息记录(wechat_messages 表) ───────────────────
|
|
516
|
+
|
|
517
|
+
insertWechatMessage(direction: 'in' | 'out', content: string): void {
|
|
518
|
+
this.db
|
|
519
|
+
.prepare('INSERT INTO wechat_messages (direction, content, created_at) VALUES (?, ?, ?)')
|
|
520
|
+
.run(direction, content.slice(0, 4000), new Date().toISOString())
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
listWechatMessages(limit = 100): Array<{ id: number; direction: string; content: string; createdAt: string }> {
|
|
524
|
+
const rows = this.db
|
|
525
|
+
.prepare(
|
|
526
|
+
'SELECT id, direction, content, created_at FROM wechat_messages ORDER BY id DESC LIMIT ?'
|
|
527
|
+
)
|
|
528
|
+
.all(limit) as unknown as Array<{
|
|
529
|
+
id: number
|
|
530
|
+
direction: string
|
|
531
|
+
content: string
|
|
532
|
+
created_at: string
|
|
533
|
+
}>
|
|
534
|
+
return rows.map((r) => ({
|
|
535
|
+
id: Number(r.id),
|
|
536
|
+
direction: r.direction,
|
|
537
|
+
content: r.content,
|
|
538
|
+
createdAt: r.created_at
|
|
539
|
+
}))
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** executions 表行 → ExecutionRecord(snake_case → camelCase) */
|
|
544
|
+
function rowToExecution(row: {
|
|
545
|
+
id: number
|
|
546
|
+
task_id: string
|
|
547
|
+
task_name: string
|
|
548
|
+
status: string
|
|
549
|
+
started_at: string
|
|
550
|
+
finished_at: string | null
|
|
551
|
+
exit_code: number | null
|
|
552
|
+
duration_ms: number | null
|
|
553
|
+
pid?: number | null
|
|
554
|
+
stdout: string
|
|
555
|
+
stderr: string
|
|
556
|
+
error: string
|
|
557
|
+
}): ExecutionRecord {
|
|
558
|
+
return {
|
|
559
|
+
id: Number(row.id),
|
|
560
|
+
taskId: row.task_id,
|
|
561
|
+
taskName: row.task_name,
|
|
562
|
+
status: row.status,
|
|
563
|
+
startedAt: row.started_at,
|
|
564
|
+
finishedAt: row.finished_at,
|
|
565
|
+
exitCode: row.exit_code == null ? null : Number(row.exit_code),
|
|
566
|
+
durationMs: row.duration_ms == null ? null : Number(row.duration_ms),
|
|
567
|
+
pid: row.pid == null ? null : Number(row.pid),
|
|
568
|
+
stdout: row.stdout ?? '',
|
|
569
|
+
stderr: row.stderr ?? '',
|
|
570
|
+
error: row.error ?? ''
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** 任务 ID 生成 */
|
|
575
|
+
export function newTaskId(): string {
|
|
576
|
+
return `task_${Math.random().toString(16).slice(2, 10)}`
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** 状态名 → 执行记录状态 */
|
|
580
|
+
export function executionStatusFor(status: TaskStatus): string {
|
|
581
|
+
if (status === TS.RUNNING) return 'running'
|
|
582
|
+
if (status === TS.FAILED) return 'failed'
|
|
583
|
+
if (status === TS.CANCELLED) return 'cancelled'
|
|
584
|
+
return 'completed'
|
|
585
|
+
}
|