cs-bots 0.1.1 → 0.7.5

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 (48) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/README.md +65 -10
  3. package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
  4. package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
  5. package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
  6. package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
  7. package/docs/ARCHITECTURE.md +277 -0
  8. package/docs/adr/ADR-001-/351/241/265/347/255/276/347/273/223/346/236/204/345/233/236/345/210/260/344/270/203/351/241/265.md +70 -0
  9. package/docs/steps/step-01-/345/216/237/346/236/266/346/236/204/345/244/215/345/216/237/344/270/216IA/350/257/212/346/226/255.md +80 -0
  10. package/docs/steps/step-02-/351/241/265/347/255/276/346/224/266/346/225/233/344/270/216/345/215/207/347/272/247/351/200/232/351/201/223.md +51 -0
  11. package/docs/steps/step-03-/346/236/266/346/236/204/350/265/204/346/226/231/346/210/220/346/226/207.md +16 -0
  12. package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
  13. package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
  14. package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
  15. package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
  16. package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
  17. package/package.json +4 -2
  18. package/src/admission.js +365 -0
  19. package/src/api.js +4097 -420
  20. package/src/board.js +883 -14
  21. package/src/botprofile.js +190 -3
  22. package/src/client.js +6596 -1082
  23. package/src/cycle.js +516 -0
  24. package/src/delivery-watch.js +255 -0
  25. package/src/dirbrowse.js +20 -0
  26. package/src/discipline.js +1188 -0
  27. package/src/fence.js +142 -0
  28. package/src/index.js +387 -4
  29. package/src/kernel.js +636 -24
  30. package/src/library.js +1224 -11
  31. package/src/memory.js +617 -37
  32. package/src/metrics.js +339 -0
  33. package/src/prompts.js +1318 -0
  34. package/src/reviewer.js +709 -0
  35. package/src/room.js +655 -14
  36. package/src/roster.js +346 -26
  37. package/src/routing/complexity.js +320 -0
  38. package/src/routing/hints.js +707 -0
  39. package/src/scratch.js +477 -0
  40. package/src/settings.js +97 -0
  41. package/src/stage.js +263 -0
  42. package/src/store.js +133 -1
  43. package/src/subscription.js +571 -0
  44. package/src/suggest.js +1203 -0
  45. package/src/teams.js +527 -0
  46. package/src/todo.js +1301 -0
  47. package/src/tools.js +734 -10
  48. package/src/verify.js +356 -0
package/src/stage.js ADDED
@@ -0,0 +1,263 @@
1
+ /**
2
+ * cs-bots StageService —— 工单执行阶段机(改造07 · 借鉴 Super-agent 的 stage table)。
3
+ *
4
+ * ## 为什么需要它
5
+ *
6
+ * 现有任务状态只有 5 个粗粒度值(pending / in_progress / completed / blocked / deleted),
7
+ * 它回答的是「这个任务还在不在做」,但**回答不了「此刻卡在谁身上」**。
8
+ * 实测痛点:任务显示 in_progress 十几小时纹丝不动,人不知道为什么——
9
+ * 是 Bot 在跑?在排队?在等人回答?还是投递失败没人知道?
10
+ *
11
+ * Super-agent 的 stage table 把「在做」切成 7 个可判定的阶段,
12
+ * 其中 4 个是「等人」(person/*),人一眼就能看出**该我动手了**。
13
+ *
14
+ * ## 三条铁律
15
+ *
16
+ * 1. **纯函数、无副作用**:阶段永远由 (Task, 最新报告, 投递状态) 派生,不落盘。
17
+ * 落盘会让阶段与事实脱钩(磁盘上的阶段是历史,不是现在)。
18
+ * 2. **表驱动、自上而下、首个命中**:与 Super-agent 的 stage table 同构,
19
+ * 调换顺序就会改变语义,所以顺序写在常量里并由测试锁死。
20
+ * 3. **绝不猜**:证据不足时落到 `working`(保守),绝不假装「等待人」骗人来看。
21
+ *
22
+ * ## 派生输入(全部可选,缺省按保守值)
23
+ *
24
+ * | 信号 | 来源字段 | 缺省 |
25
+ * |---|---|---|
26
+ * | 任务状态 | `task.status` | 必填 |
27
+ * | 最新报告 | `task.latestReport` | 无 → 视为未报告 |
28
+ * | 投递失败 | `task.delivery` | 无 → 视为投递正常 |
29
+ * | 有无 Assignment | `task.ownerId` / `task.lease` | 无 → 未开工 |
30
+ * | 是否被直接中止 | `task.blockedReason` | 无 → 未中止 |
31
+ *
32
+ * ## 依赖面
33
+ *
34
+ * 零依赖:只用 node 内置。不 import 任何 `@deepseek-ai/*`(CI 检查 1 覆盖)。
35
+ */
36
+
37
+ /** 阶段枚举(值即稳定 id,落进 API 与前端文案,**不可改名**)。 */
38
+ export const STAGES = Object.freeze({
39
+ PERSON_DELIVERY: 'person/delivery',
40
+ PERSON_ANSWER: 'person/answer',
41
+ PERSON_REVIEW: 'person/review',
42
+ PERSON_BLOCKED: 'person/blocked',
43
+ PERSON_FAILED: 'person/failed',
44
+ WORKING_QUEUED: 'working/queued',
45
+ IDLE: 'idle',
46
+ WORKING: 'working',
47
+ })
48
+
49
+ /** 全部阶段(顺序 = 判定优先级,自上而下首个命中)。 */
50
+ export const STAGE_ORDER = Object.freeze([
51
+ STAGES.PERSON_DELIVERY,
52
+ STAGES.PERSON_ANSWER,
53
+ STAGES.PERSON_REVIEW,
54
+ STAGES.PERSON_BLOCKED,
55
+ STAGES.PERSON_FAILED,
56
+ STAGES.WORKING_QUEUED,
57
+ STAGES.IDLE,
58
+ STAGES.WORKING,
59
+ ])
60
+
61
+ /** 阶段 → 人类可读标签(界面直接展示,不暴露 `/` 与英文)。 */
62
+ export const STAGE_LABEL = Object.freeze({
63
+ [STAGES.PERSON_DELIVERY]: '投递失败',
64
+ [STAGES.PERSON_ANSWER]: '等你回答',
65
+ [STAGES.PERSON_REVIEW]: '等你验收',
66
+ [STAGES.PERSON_BLOCKED]: '受阻等你',
67
+ [STAGES.PERSON_FAILED]: '失败等你',
68
+ [STAGES.WORKING_QUEUED]: '排队中',
69
+ [STAGES.IDLE]: '空闲等触发',
70
+ [STAGES.WORKING]: '正在干',
71
+ })
72
+
73
+ /**
74
+ * 阶段 → 语义分组(前端配色与「是否要人」判定用)。
75
+ * - `person`:必须人动手,否则永远不动 → 强调色
76
+ * - `working`:机器在推进 → 常规色
77
+ * - `idle`:可继续但无人在推 → 弱化色
78
+ */
79
+ export const STAGE_GROUP = Object.freeze({
80
+ [STAGES.PERSON_DELIVERY]: 'person',
81
+ [STAGES.PERSON_ANSWER]: 'person',
82
+ [STAGES.PERSON_REVIEW]: 'person',
83
+ [STAGES.PERSON_BLOCKED]: 'person',
84
+ [STAGES.PERSON_FAILED]: 'person',
85
+ [STAGES.WORKING_QUEUED]: 'working',
86
+ [STAGES.IDLE]: 'idle',
87
+ [STAGES.WORKING]: 'working',
88
+ })
89
+
90
+ /** 报告取值枚举(Super-agent 的 Report 六态,映射到 cs-bots 的 latestReport)。 */
91
+ export const REPORT_STATES = Object.freeze([
92
+ 'progress', 'waiting', 'ready', 'blocked', 'failed', 'dropped',
93
+ ])
94
+
95
+ /**
96
+ * 判定一个任务的执行阶段。
97
+ *
98
+ * @param {object} task 任务(board 的 Task 形状;只读,绝不修改)
99
+ * @returns {{
100
+ * stage: string, label: string, group: 'person'|'working'|'idle',
101
+ * needsPerson: boolean, reason: string,
102
+ * }}
103
+ *
104
+ * @example
105
+ * deriveStage({ status: 'in_progress', ownerId: 'bot-a' })
106
+ * // → { stage: 'working', label: '正在干', needsPerson: false, ... }
107
+ */
108
+ export function deriveStage(task) {
109
+ const t = task && typeof task === 'object' ? task : {}
110
+
111
+ // 终态优先:已删除/已完成的任务没有「执行阶段」概念,直接给稳定结果,
112
+ // 避免前端对终态任务显示出「等你验收」这类误导。
113
+ if (t.status === 'deleted') {
114
+ return result(STAGES.IDLE, '已删除(终态,无执行阶段)')
115
+ }
116
+ if (t.status === 'completed') {
117
+ // 已完成但未验收 → 仍是「等人验收」,这是真实需要人动手的事。
118
+ if (t.accepted !== true) return result(STAGES.PERSON_REVIEW, '已完成,等待验收')
119
+ return result(STAGES.IDLE, '已完成且已验收')
120
+ }
121
+
122
+ // 1. 投递失败:最高优先级——它是「本该发生却没发生」的静默失败,
123
+ // 人不处理就永远不会恢复,必须压过所有其它阶段。
124
+ if (hasDeliveryFailure(t)) {
125
+ return result(STAGES.PERSON_DELIVERY, `输入投递失败:${deliveryError(t)}`)
126
+ }
127
+
128
+ // 2. 等待回答:最新报告带结构化问题(Super-agent 的 Attention)。
129
+ if (hasAttention(t)) {
130
+ return result(STAGES.PERSON_ANSWER, '执行者提了问题,等你回答')
131
+ }
132
+
133
+ // 3. 受阻/失败:最新报告如此,且此后没有新的人类消息解锁。
134
+ const report = reportState(t)
135
+ if (report === 'blocked') return result(STAGES.PERSON_BLOCKED, '执行者报告受阻')
136
+ if (report === 'failed') return result(STAGES.PERSON_FAILED, '执行者报告失败')
137
+
138
+ // 4. 等待验收:报告 ready(交付物已就绪)。
139
+ if (report === 'ready') return result(STAGES.PERSON_REVIEW, '交付物就绪,等你验收')
140
+
141
+ // 5. 排队中:任务已激活但还没有人领(无 owner / 无租约)。
142
+ if (!hasOwner(t)) {
143
+ // 2026-11-09 现场:「建了任务、也指派给了 Bot,页面却只显示『排队中』」。
144
+ // 指派(`assignee`)与持有(`ownerId` / 租约)是两件事:指派只是把活放到了
145
+ // 某人名下,真正开工由对方 claim 产生租约。但「有人管」和「没人管」对用户
146
+ // 是**完全不同的两件事**,不能都叫「排队中」——
147
+ // 有 assignee ⇒ 已经有人负责,在等对方接活(可重试唤醒 / 换人)
148
+ // 无 assignee ⇒ 真的没人管,需要先指派
149
+ const assignee = typeof t.assignee === 'string' && t.assignee.trim() !== '' ? t.assignee.trim() : ''
150
+ if (assignee) {
151
+ return result(STAGES.WORKING_QUEUED, `已指派给 ${shortId(assignee)},等待对方接活`)
152
+ }
153
+ return result(STAGES.WORKING_QUEUED, '尚未指派,等待分配给某个 Bot')
154
+ }
155
+
156
+ // 6. 空闲:有人领、但最新报告是 waiting,说明这一轮干完了,等新触发。
157
+ if (report === 'waiting') return result(STAGES.IDLE, '执行者已交付本轮,等待新触发')
158
+
159
+ // 7. 兜底:正在干(保守,不猜「等谁」)。
160
+ return result(STAGES.WORKING, '执行者持有租约,正在推进')
161
+ }
162
+
163
+ /** 构造统一返回形状(保证四个字段恒存在,前端不用做 null 判断)。 */
164
+ function result(stage, reason) {
165
+ const group = STAGE_GROUP[stage] ?? 'working'
166
+ return {
167
+ stage,
168
+ label: STAGE_LABEL[stage] ?? stage,
169
+ group,
170
+ needsPerson: group === 'person',
171
+ reason,
172
+ }
173
+ }
174
+
175
+ /** 会话 id 缩写(`bot-6b654c19-…` → `6b654c19`)—— 页面上一行长文案要短。 */
176
+ function shortId(id) {
177
+ const raw = String(id)
178
+ const m = /^bot-([0-9a-f]{8})/.exec(raw)
179
+ if (m) return m[1]
180
+ return raw.length > 12 ? `${raw.slice(0, 12)}…` : raw
181
+ }
182
+
183
+ /** 是否有投递失败(`task.delivery` 存在且未被清除)。 */
184
+ function hasDeliveryFailure(t) {
185
+ const d = t.delivery
186
+ if (!d) return false
187
+ // 约定:delivery 为对象且有 error 字段 = 最近一次投递失败且尚未被成功投递清除。
188
+ return typeof d === 'object' && typeof d.error === 'string' && d.error.length > 0
189
+ }
190
+
191
+ /** 投递失败的可读原因。 */
192
+ function deliveryError(t) {
193
+ const e = t.delivery?.error
194
+ return typeof e === 'string' && e ? e : '未知原因'
195
+ }
196
+
197
+ /** 是否有待回答的问题(最新报告里带 attention 字段)。 */
198
+ function hasAttention(t) {
199
+ const r = t.latestReport
200
+ if (!r || typeof r !== 'object') return false
201
+ const a = r.attention
202
+ if (!a) return false
203
+ // 支持两种形状:字符串(问题原文)与对象({ question } / { text })
204
+ if (typeof a === 'string') return a.trim().length > 0
205
+ if (typeof a === 'object') {
206
+ const text = a.question ?? a.text ?? a.content
207
+ return typeof text === 'string' && text.trim().length > 0
208
+ }
209
+ return false
210
+ }
211
+
212
+ /**
213
+ * 最新报告的规范状态。
214
+ * 支持字符串(`'ready'`)与对象(`{ state: 'ready' }` / `{ status }` / `{ report }`)三种历史形状,
215
+ * 因为 latestReport 的写入方前后演进过,读侧必须宽容。
216
+ */
217
+ function reportState(t) {
218
+ const r = t.latestReport
219
+ if (!r) return null
220
+ if (typeof r === 'string') return REPORT_STATES.includes(r) ? r : null
221
+ if (typeof r !== 'object') return null
222
+ const s = r.state ?? r.status ?? r.report
223
+ return typeof s === 'string' && REPORT_STATES.includes(s) ? s : null
224
+ }
225
+
226
+ /** 是否有人持有(owner 或未过期租约任一存在即算)。 */
227
+ function hasOwner(t) {
228
+ if (typeof t.ownerId === 'string' && t.ownerId.length > 0) return true
229
+ const lease = t.lease
230
+ if (!lease || typeof lease !== 'object') return false
231
+ const exp = Number(lease.expiresAt)
232
+ return Number.isFinite(exp) && exp > Date.now()
233
+ }
234
+
235
+ /**
236
+ * 批量派生阶段(Board 列表页用;纯函数、不修改入参)。
237
+ *
238
+ * @param {object[]} tasks
239
+ * @returns {Array<object & { stageInfo: object }>}
240
+ */
241
+ export function attachStages(tasks) {
242
+ const list = Array.isArray(tasks) ? tasks : []
243
+ return list.map((t) => ({ ...t, stageInfo: deriveStage(t) }))
244
+ }
245
+
246
+ /**
247
+ * 阶段统计(面板概览用):按语义分组计数。
248
+ *
249
+ * @param {object[]} tasks
250
+ * @returns {{ person: number, working: number, idle: number, total: number }}
251
+ */
252
+ export function stageSummary(tasks) {
253
+ const out = { person: 0, working: 0, idle: 0, total: 0 }
254
+ for (const t of Array.isArray(tasks) ? tasks : []) {
255
+ if (t?.status === 'deleted') continue
256
+ const s = deriveStage(t)
257
+ out[s.group] = (out[s.group] ?? 0) + 1
258
+ out.total += 1
259
+ }
260
+ return out
261
+ }
262
+
263
+ export default { STAGES, STAGE_ORDER, STAGE_LABEL, STAGE_GROUP, REPORT_STATES, deriveStage, attachStages, stageSummary }
package/src/store.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * 本文件不 import 任何 `@deepseek-ai/*`(零依赖 CI 检查 1 覆盖)。
13
13
  */
14
14
 
15
- import { mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, existsSync, rmSync, statSync } from 'node:fs'
15
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, existsSync, rmSync, statSync, cpSync } from 'node:fs'
16
16
  import { dirname, join } from 'node:path'
17
17
 
18
18
  /** 确保目录存在。 */
@@ -241,3 +241,135 @@ export function fileMtime(file) {
241
241
  export function statePath(dir, ...parts) {
242
242
  return join(dir, ...parts)
243
243
  }
244
+
245
+ // ── Schema 版本 / 迁移 / 审计 / 备份(P0-A 数据基座)────────────────────────
246
+
247
+ /** 当前全局 schema 版本基线(各域可各自覆盖)。 */
248
+ export const SCHEMA_VERSION = 1
249
+
250
+ function _tsLabel() {
251
+ const d = new Date()
252
+ const pad = (n) => String(n).padStart(2, '0')
253
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
254
+ }
255
+
256
+ /** 读取某域的 schema 版本(缺省 0)。 */
257
+ export function readSchemaVersion(dir, name) {
258
+ const v = readJson(statePath(dir, `${name}.schema.json`), 0)
259
+ if (typeof v === 'number' && Number.isFinite(v)) return v
260
+ if (v && typeof v === 'object' && typeof v.version === 'number') return v.version
261
+ return 0
262
+ }
263
+
264
+ /** 写入某域的 schema 版本(原子)。 */
265
+ export function writeSchemaVersion(dir, name, version) {
266
+ if (!Number.isFinite(version)) return false
267
+ return writeJsonAtomic(statePath(dir, `${name}.schema.json`), { version, at: Date.now() })
268
+ }
269
+
270
+ /**
271
+ * 版本迁移:版本不符时调用 migrateFn(rows) 并原子写回;版本高于当前抛可读错误(不降级)。
272
+ * @returns {{ ok: true, migrated: boolean, version: number }}
273
+ */
274
+ export function migrate(stateDir, { name, from, to, migrateFn } = {}) {
275
+ if (!name) throw new Error('migrate: name 必填')
276
+ if (!Number.isFinite(to)) throw new Error('migrate: to 必须是有限数')
277
+ const current = readSchemaVersion(stateDir, name)
278
+ if (current > to) {
279
+ throw new Error(`migrate: ${name} 当前版本 ${current} 高于目标 ${to},拒绝降级`)
280
+ }
281
+ if (current === to) return { ok: true, migrated: false, version: current }
282
+ if (from !== undefined && current !== from) {
283
+ // 允许 from 未给;给了就必须匹配
284
+ throw new Error(`migrate: ${name} 当前版本 ${current} 与 from=${from} 不符`)
285
+ }
286
+ if (typeof migrateFn !== 'function') throw new Error('migrate: migrateFn 必须是函数')
287
+ const jsonlPath = statePath(stateDir, `${name}.jsonl`)
288
+ const jsonPath = statePath(stateDir, `${name}.json`)
289
+ // 形态记忆:JSONL 恒为数组;JSON 是「数组」还是「单个对象」由原文件决定,
290
+ // 迁移后**按原形态写回**(否则调用方读到的结构会突然从对象变成数组,属于隐性破坏)。
291
+ let rows = []
292
+ let target = 'jsonl'
293
+ let wasArray = true
294
+ if (existsSync(jsonlPath)) {
295
+ const parsed = readJsonl(jsonlPath)
296
+ rows = parsed.rows
297
+ target = 'jsonl'
298
+ if (parsed.bad > 0) {
299
+ // 不静默:坏行要让调用方看见
300
+ throw new Error(`migrate: ${name}.jsonl 有 ${parsed.bad}/${parsed.total} 行无法解析,拒绝在不完整数据上迁移`)
301
+ }
302
+ } else if (existsSync(jsonPath)) {
303
+ const raw = readJson(jsonPath, undefined)
304
+ if (raw === undefined) throw new Error(`migrate: ${name}.json 无法解析,拒绝迁移`)
305
+ wasArray = Array.isArray(raw)
306
+ rows = wasArray ? raw : [raw]
307
+ target = 'json'
308
+ }
309
+ const next = migrateFn(rows)
310
+ if (next !== undefined && !Array.isArray(next)) {
311
+ throw new Error('migrate: migrateFn 必须返回数组(拿到 ' + typeof next + ')')
312
+ }
313
+ const nextRows = next ?? rows
314
+ ensureDir(stateDir)
315
+ if (target === 'jsonl') {
316
+ const tmp = `${jsonlPath}.tmp-${process.pid}`
317
+ writeFileSync(tmp, nextRows.map((r) => JSON.stringify(r)).join('\n') + (nextRows.length ? '\n' : ''), 'utf8')
318
+ renameSync(tmp, jsonlPath)
319
+ } else if (existsSync(jsonPath) || nextRows.length > 0) {
320
+ writeJsonAtomic(jsonPath, wasArray ? nextRows : nextRows[0])
321
+ }
322
+ writeSchemaVersion(stateDir, name, to)
323
+ return { ok: true, migrated: true, version: to }
324
+ }
325
+
326
+ /** 追加审计记录到 audit.jsonl(append-only)。 */
327
+ export function appendAudit(stateDir, entry = {}) {
328
+ try {
329
+ if (!stateDir) return false
330
+ if (!ensureDir(stateDir)) return false
331
+ // 顺序:先铺默认值,再让 entry 覆盖 —— 保证 ts/actor/action/target 一定存在,
332
+ // 同时保留调用方给的任意额外字段(before/after/diagnostics 等)。
333
+ const rec = {
334
+ ts: Date.now(),
335
+ actor: 'unknown',
336
+ action: 'unknown',
337
+ target: null,
338
+ ...(entry && typeof entry === 'object' ? entry : {}),
339
+ }
340
+ if (typeof rec.ts !== 'number') rec.ts = Date.now()
341
+ return appendJsonl(statePath(stateDir, 'audit.jsonl'), rec)
342
+ } catch {
343
+ return false
344
+ }
345
+ }
346
+
347
+ /** 递归备份状态目录。 */
348
+ export function backupState(stateDir, label = 'manual') {
349
+ try {
350
+ if (!stateDir || !existsSync(stateDir)) return { ok: false, reason: 'stateDir 不存在或为空' }
351
+ const safeLabel = String(label || 'manual').replace(/[^a-zA-Z0-9_-]/g, '_')
352
+ const dest = `${stateDir}.bak-${_tsLabel()}-${safeLabel}`
353
+ cpSync(stateDir, dest, { recursive: true, force: true })
354
+ return { ok: true, path: dest }
355
+ } catch (error) {
356
+ return { ok: false, reason: `备份失败:${error?.message ?? error}` }
357
+ }
358
+ }
359
+
360
+ /** 恢复状态目录。confirm!==true 时拒绝。 */
361
+ export function restoreState(stateDir, backupPath, { confirm } = {}) {
362
+ if (confirm !== true) return { ok: false, reason: '需要 confirm:true' }
363
+ try {
364
+ if (!backupPath || !existsSync(backupPath)) return { ok: false, reason: 'backupPath 不存在' }
365
+ // 先把当前目录改名
366
+ if (existsSync(stateDir)) {
367
+ const pre = `${stateDir}.pre-restore-${_tsLabel()}`
368
+ renameSync(stateDir, pre)
369
+ }
370
+ cpSync(backupPath, stateDir, { recursive: true, force: true })
371
+ return { ok: true, path: stateDir }
372
+ } catch (error) {
373
+ return { ok: false, reason: `恢复失败:${error?.message ?? error}` }
374
+ }
375
+ }