@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.
Files changed (76) hide show
  1. package/README.md +394 -0
  2. package/bin/deskpet.mjs +142 -0
  3. package/dist/web/assets/DashboardView-BLXa7VrZ.css +1 -0
  4. package/dist/web/assets/DashboardView-CTyWcLtQ.js +60 -0
  5. package/dist/web/assets/SettingsView-7c3RiRrt.js +1 -0
  6. package/dist/web/assets/SettingsView-BkL0OpZC.css +1 -0
  7. package/dist/web/assets/TasksView-BTK1OTwU.css +1 -0
  8. package/dist/web/assets/TasksView-s1MaR5sZ.js +5 -0
  9. package/dist/web/assets/ToolsView-B8V-A1j_.js +178 -0
  10. package/dist/web/assets/ToolsView-DGLJATQ9.css +1 -0
  11. package/dist/web/assets/WeChatView-ChLBhPso.js +1 -0
  12. package/dist/web/assets/WeChatView-CvdhJ05E.css +1 -0
  13. package/dist/web/assets/browser-CjSdxGTc.js +8 -0
  14. package/dist/web/assets/dashboard-DJ_Miuzx.js +93 -0
  15. package/dist/web/assets/dashboard-Fbcagzwx.css +1 -0
  16. package/dist/web/dashboard/index.html +13 -0
  17. package/package.json +71 -0
  18. package/resources/icons/tray.png +0 -0
  19. package/resources/icons/tray@2x.png +0 -0
  20. package/resources/previews/busy.png +0 -0
  21. package/resources/previews/click.png +0 -0
  22. package/resources/previews/hover.png +0 -0
  23. package/resources/previews/idle.png +0 -0
  24. package/resources/previews/preview.png +0 -0
  25. package/resources/previews/sleep.png +0 -0
  26. package/resources/skins/default_cute/pet.json +7 -0
  27. package/resources/skins/default_cute/spritesheet.webp +0 -0
  28. package/resources/skins/default_cute/submission.json +29 -0
  29. package/server/db/database.ts +118 -0
  30. package/server/db/migrate-legacy.ts +121 -0
  31. package/server/db/task-repository.ts +585 -0
  32. package/server/http/http-server.ts +725 -0
  33. package/server/http/ws-hub.ts +66 -0
  34. package/server/main.ts +322 -0
  35. package/server/plugins/actions/builtin.ts +73 -0
  36. package/server/plugins/actions/clipboard-watch.ts +38 -0
  37. package/server/plugins/actions/http-request.ts +53 -0
  38. package/server/plugins/actions/jenkins-build.ts +209 -0
  39. package/server/plugins/actions/open-app.ts +40 -0
  40. package/server/plugins/actions/python-script.ts +187 -0
  41. package/server/plugins/actions/screenshot.ts +41 -0
  42. package/server/plugins/actions/send-keystroke.ts +103 -0
  43. package/server/plugins/actions/show-reminder.ts +16 -0
  44. package/server/plugins/actions/ssh-command.ts +148 -0
  45. package/server/plugins/actions/task-chain.ts +30 -0
  46. package/server/plugins/actions/volume-control.ts +35 -0
  47. package/server/plugins/index.ts +37 -0
  48. package/server/plugins/registry.ts +72 -0
  49. package/server/services/clipboard-watcher.ts +126 -0
  50. package/server/services/config-store.ts +152 -0
  51. package/server/services/idle-monitor.ts +146 -0
  52. package/server/services/notifier.ts +54 -0
  53. package/server/services/quick-actions-store.ts +54 -0
  54. package/server/services/remote-connector.ts +75 -0
  55. package/server/services/scanner-reader.ts +257 -0
  56. package/server/services/script-runner.ts +263 -0
  57. package/server/services/snapshot-service.ts +196 -0
  58. package/server/services/task-scheduler.ts +900 -0
  59. package/server/services/wechat-bot.ts +744 -0
  60. package/server/services/wechat-command-types.ts +15 -0
  61. package/server/services/wechat-commands.ts +367 -0
  62. package/server/suppress-warnings.ts +10 -0
  63. package/server/utils/asset-url.ts +27 -0
  64. package/server/utils/auto-start.ts +90 -0
  65. package/server/utils/clipboard.ts +50 -0
  66. package/server/utils/dashboard-url.ts +9 -0
  67. package/server/utils/instance-guard.ts +170 -0
  68. package/server/utils/native-notify.ts +68 -0
  69. package/server/utils/open.ts +38 -0
  70. package/server/utils/paths.ts +72 -0
  71. package/server/utils/python-interpreter.ts +154 -0
  72. package/shared/animation-engine.ts +422 -0
  73. package/shared/chain-condition.ts +60 -0
  74. package/shared/cron-weekly.ts +131 -0
  75. package/shared/py-task-params.ts +356 -0
  76. 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
+ }