sage-mem 0.7.2 → 0.9.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.
@@ -0,0 +1,221 @@
1
+ /**
2
+ * autodream —— 回滚与留痕(引擎方法体)。
3
+ *
4
+ * 本文件里的函数都是 `AutodreamEngine` 的方法体:`engine.js` 里保留同名同签名的方法,
5
+ * 用 `xxxMod.name.call(this, …)` 转发进来 —— 所以函数体里的 `this` 就是引擎实例,
6
+ * 与拆分前完全一致,没有引入任何模块级可变状态。
7
+ */
8
+
9
+ import { REPORT_DIR } from './config.js'
10
+ import { readJson, safeName, snapshotEntries, stampCompact, stampHuman } from './util.js'
11
+ import { randomUUID } from 'node:crypto'
12
+ import { copyFile, mkdir, readdir, rename, stat, writeFile } from 'node:fs/promises'
13
+ import { join } from 'node:path'
14
+
15
+ /** 读回滚留痕(面板也许要显示「最近谁退回过」)。 */
16
+ export async function listRollbacks(limit = 20) {
17
+ const all = await readJson(this.rollbacksPath)
18
+ const list = Array.isArray(all) ? all : []
19
+ return list.slice(-limit).reverse()
20
+ }
21
+
22
+ /** 追加一条回滚留痕。 */
23
+ export async function appendRollback(entry) {
24
+ const all = await readJson(this.rollbacksPath)
25
+ const list = Array.isArray(all) ? all : []
26
+ list.push(entry)
27
+ await mkdir(this.home, { recursive: true })
28
+ await writeFile(this.rollbacksPath, JSON.stringify(list.slice(-200), null, 2), 'utf8')
29
+ }
30
+
31
+ /** 把运行记录标成「已被回滚」。 */
32
+ export async function markRunRolledBack(runId, at) {
33
+ const id = safeName(runId)
34
+ if (!id) return
35
+ const file = join(this.runsRoot, id, 'manifest.json')
36
+ const m = await readJson(file)
37
+ if (!m) return
38
+ m.rolledBackAt = at
39
+ m.rolledBackAtHuman = stampHuman(at)
40
+ await writeFile(file, JSON.stringify(m, null, 2), 'utf8')
41
+ }
42
+
43
+ /**
44
+ * 把记忆目录退回到某个回滚点。
45
+ *
46
+ * 四步顺序不可变(见 contract §5):
47
+ * ① 保护快照 —— 回滚本身也可能滚错,所以回滚前先把「现在」存下来
48
+ * ② 恢复 —— `all` 整目录;`files` 只动该次运行动过的文件
49
+ * ③ 留痕 —— 报告 + `rollbacks.json`
50
+ * ④ 回写运行记录
51
+ *
52
+ * **全程不删文件**:回滚中「不该存在」的文件一律移进 `archive/`(人工可捞回)。
53
+ * memory 目录没有版本控制也没有回收站,`unlink` 不该出现在这条路径上。
54
+ *
55
+ * @param {{snapshotId:string, scope?:'files'|'all'}} opts
56
+ */
57
+ export async function rollback(opts = {}) {
58
+ const snapshotId = safeName(opts?.snapshotId)
59
+ const scope = opts?.scope === 'all' ? 'all' : 'files'
60
+ if (!snapshotId) return { ok: false, error: '快照 id 不合法' }
61
+ if (this.running) return { ok: false, error: '正在整理中,等它跑完再回滚' }
62
+
63
+ const snapDir = await this.resolveSnapshotDir(snapshotId)
64
+ if (!snapDir) return { ok: false, error: `找不到回滚点:${snapshotId}` }
65
+
66
+ await this.load()
67
+ if (!(await this.acquireLock())) return { ok: false, error: '拿不到锁(另一趟整理正在跑?)' }
68
+ const startedAt = Date.now()
69
+ const stamp = stampCompact(startedAt)
70
+ try {
71
+ const manifest = await readJson(join(snapDir, 'MANIFEST.json'))
72
+ const runId = manifest?.runId ?? null
73
+
74
+ // 文件级回滚要知道「这次动过哪几个文件」——那信息在运行记录里,不在快照里。
75
+ let runManifest = null
76
+ if (scope === 'files') {
77
+ if (!runId) return { ok: false, error: '这份回滚点没有绑定运行记录(旧版快照),只能选「整目录恢复」' }
78
+ runManifest = await readJson(join(this.runsRoot, runId, 'manifest.json'))
79
+ if (!runManifest) return { ok: false, error: `找不到回滚点 ${snapshotId} 对应的运行记录,只能选「整目录恢复」` }
80
+ }
81
+
82
+ // ① 保护快照
83
+ this.phase = '回滚前保护快照'
84
+ // 带随机后缀:秒级时间戳会撞(同一秒内连点两次回滚是真实用法),
85
+ // 而撞车的后果是**覆盖掉上一次的保护快照**——那等于把「回滚也能退」这条承诺吃掉。
86
+ const protectionId = `pre-rollback-${stamp}-${randomUUID().replace(/-/g, '').slice(0, 4)}`
87
+ const protection = await this.snapshot(protectionId, 'pre-rollback')
88
+
89
+ // ② 恢复
90
+ this.phase = '恢复文件'
91
+ const snapNames = await snapshotEntries(snapDir)
92
+ const snapSet = new Set(snapNames)
93
+ const current = (await readdir(this.memoryDir, { withFileTypes: true }).catch(() => []))
94
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
95
+ .map((e) => e.name)
96
+
97
+ const restored = []
98
+ const parked = []
99
+ const skipped = []
100
+ const archiveDir = join(this.memoryDir, 'archive')
101
+ await mkdir(archiveDir, { recursive: true })
102
+
103
+ /** 移进 archive/(不删)。同名冲突就跳过并记账,绝不覆盖。 */
104
+ const park = async (name, why) => {
105
+ const target = join(archiveDir, name)
106
+ const clash = await stat(target).catch(() => null)
107
+ if (clash) {
108
+ skipped.push(`${name}(本想${why},但 archive/ 里已有同名文件)`)
109
+ return false
110
+ }
111
+ await rename(join(this.memoryDir, name), target)
112
+ parked.push(`${name}(${why})`)
113
+ return true
114
+ }
115
+
116
+ if (scope === 'all') {
117
+ for (const name of snapNames) {
118
+ try {
119
+ await copyFile(join(snapDir, name), join(this.memoryDir, name))
120
+ restored.push(name)
121
+ } catch (err) {
122
+ skipped.push(`${name}(写回失败:${err?.message ?? err})`)
123
+ }
124
+ }
125
+ // 快照之后新建的文件:整目录回滚要让它退场,但不能删——移进 archive/。
126
+ for (const name of current) {
127
+ if (!snapSet.has(name)) await park(name, '快照里没有,整目录回滚需退场')
128
+ }
129
+ } else {
130
+ for (const c of runManifest.changes ?? []) {
131
+ const name = c.file
132
+ if (c.op === 'update') {
133
+ if (snapSet.has(name)) {
134
+ try {
135
+ await copyFile(join(snapDir, name), join(this.memoryDir, name))
136
+ restored.push(name)
137
+ } catch (err) {
138
+ skipped.push(`${name}(写回失败:${err?.message ?? err})`)
139
+ }
140
+ } else {
141
+ skipped.push(`${name}(回滚点里没有这一版,可能已被别的整理改过)`)
142
+ }
143
+ } else if (c.op === 'create') {
144
+ if (current.includes(name)) await park(name, '本次整理新建,回滚即退场')
145
+ } else if (c.op === 'archive') {
146
+ // 归档 = 从顶层移进 archive/;回滚就是把它移回来。
147
+ const src = join(archiveDir, name)
148
+ const exists = await stat(src).catch(() => null)
149
+ const atTop = await stat(join(this.memoryDir, name)).catch(() => null)
150
+ if (!exists) skipped.push(`${name}(archive/ 里找不到,可能已人工处理过)`)
151
+ else if (atTop) skipped.push(`${name}(顶层已有同名文件,不覆盖)`)
152
+ else {
153
+ await rename(src, join(this.memoryDir, name))
154
+ restored.push(name)
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ // ③ 留痕
161
+ this.phase = '写回滚报告'
162
+ const reportName = `${stamp}-rollback.md`
163
+ const lines = [
164
+ `# Autodream 回滚报告 · ${stampHuman(startedAt)}`,
165
+ '',
166
+ `- 回滚目标:\`${snapshotId}\`${runId ? `(运行 \`${runId}\`)` : '(旧版快照)'}`,
167
+ `- 范围:${scope === 'all' ? '整目录恢复到该时点' : '只恢复该次运行动过的文件'}`,
168
+ `- 回滚前保护快照:\`${protection.id}\`(${protection.count} 个文件)—— 滚错了还能再退回来`,
169
+ '',
170
+ '## 写回的文件',
171
+ '',
172
+ restored.length ? restored.map((f) => `- \`${f}\``).join('\n') : '- (没有文件被写回)',
173
+ '',
174
+ '## 移进 archive/ 的文件(没有删除)',
175
+ '',
176
+ parked.length ? parked.map((f) => `- ${f}`).join('\n') : '- (没有)',
177
+ '',
178
+ ]
179
+ if (skipped.length) {
180
+ lines.push('## 跳过', '', ...skipped.map((f) => `- ${f}`), '')
181
+ }
182
+ const reportDir = join(this.memoryDir, REPORT_DIR)
183
+ await mkdir(reportDir, { recursive: true })
184
+ await writeFile(join(reportDir, reportName), lines.join('\n'), 'utf8')
185
+
186
+ const entry = {
187
+ at: startedAt,
188
+ atHuman: stampHuman(startedAt),
189
+ snapshotId,
190
+ scope,
191
+ runId,
192
+ restored: restored.length,
193
+ parked: parked.length,
194
+ skipped: skipped.length,
195
+ protection: protection.id,
196
+ report: reportName,
197
+ }
198
+ await this.appendRollback(entry)
199
+ // ④ 回写运行记录
200
+ if (runId) await this.markRunRolledBack(runId, startedAt)
201
+
202
+ this.phase = ''
203
+ return {
204
+ ok: true,
205
+ restored: restored.length,
206
+ parked: parked.length,
207
+ skipped: skipped.length,
208
+ protection: protection.id,
209
+ report: reportName,
210
+ }
211
+ } catch (err) {
212
+ const message = err?.message ?? String(err)
213
+ this.log('warn', `回滚失败:${message}`)
214
+ this.phase = ''
215
+ return { ok: false, error: message }
216
+ } finally {
217
+ await this.releaseLock()
218
+ }
219
+ }
220
+
221
+ // ────────────────────────────── 主流程 ──────────────────────────────
@@ -0,0 +1,289 @@
1
+ /**
2
+ * autodream —— 配置与状态(引擎方法体)。
3
+ *
4
+ * 本文件里的函数都是 `AutodreamEngine` 的方法体:`engine.js` 里保留同名同签名的方法,
5
+ * 用 `xxxMod.name.call(this, …)` 转发进来 —— 所以函数体里的 `this` 就是引擎实例,
6
+ * 与拆分前完全一致,没有引入任何模块级可变状态。
7
+ */
8
+
9
+ import { CONFIG_VERSION, DEFAULT_CONFIG, LEGACY_STATE_FILE } from './config.js'
10
+ import { clampNum, readJson } from './util.js'
11
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
12
+
13
+ /**
14
+ * 读配置 + 状态(同一个文件)。缺失或损坏都回落到默认值,绝不让它拖死插件加载。
15
+ *
16
+ * **改名迁移**:新文件 `<stateRoot>/autodream.json` 不存在而旧文件 `<stateRoot>/dream.json`
17
+ * 存在时,读旧文件、把配置搬到新文件,**旧文件原样留着不删**(用户手改过的东西,
18
+ * 插件没有替用户清理的资格;留着也让人能对照)。迁移只发生一次。
19
+ */
20
+ export async function load() {
21
+ if (this._configLoaded) return this.state
22
+ let parsed = await readJson(this.configPath)
23
+ let migratedFrom = ''
24
+ if (!parsed) {
25
+ const legacy = await readJson(this.legacyConfigPath)
26
+ if (legacy && typeof legacy === 'object') {
27
+ parsed = legacy
28
+ migratedFrom = LEGACY_STATE_FILE
29
+ }
30
+ }
31
+ if (parsed && typeof parsed === 'object') {
32
+ this.state = {
33
+ version: CONFIG_VERSION,
34
+ config: { ...DEFAULT_CONFIG, ...(parsed.config ?? {}) },
35
+ lastRunAt: Number(parsed.lastRunAt) || 0,
36
+ lastResult: parsed.lastResult ?? null,
37
+ retryAfter: Number(parsed.retryAfter) || 0,
38
+ }
39
+ } else {
40
+ this.state = { version: CONFIG_VERSION, config: { ...DEFAULT_CONFIG }, lastRunAt: 0, lastResult: null, retryAfter: 0 }
41
+ }
42
+ this._configLoaded = true
43
+ if (migratedFrom) {
44
+ this.log('warn', `读到旧版配置 ${this.legacyConfigPath},已迁移到 ${this.configPath}(旧文件保留不动)`)
45
+ await this.persist().catch((err) => this.log('warn', `迁移落盘失败(本次仍按内存里的配置跑):${err?.message ?? err}`))
46
+ }
47
+ return this.state
48
+ }
49
+
50
+ /** 落盘。写入是原子的(临时文件 + rename),半截文件不会留在盘上。 */
51
+ export async function persist() {
52
+ await mkdir(this.stateRoot, { recursive: true })
53
+ const tmp = `${this.configPath}.${process.pid}.tmp`
54
+ try {
55
+ await writeFile(tmp, JSON.stringify(this.state, null, 2), 'utf8')
56
+ await rename(tmp, this.configPath)
57
+ } finally {
58
+ // rename 成功后 tmp 已不存在(ENOENT),失败时清掉不留垃圾。
59
+ await rm(tmp, { force: true }).catch(() => {})
60
+ }
61
+ }
62
+
63
+ /** 给 UI 的完整配置(含有效模型路线与门控快照)。 */
64
+ export async function getConfig() {
65
+ await this.load()
66
+ const route = this.resolveRoute(this.state.config)
67
+ return {
68
+ config: { ...this.state.config },
69
+ route,
70
+ lastRunAt: this.state.lastRunAt,
71
+ lastResult: this.state.lastResult,
72
+ paths: { memoryDir: this.memoryDir, configPath: this.configPath, stateRoot: this.stateRoot, home: this.home },
73
+ defaults: { ...DEFAULT_CONFIG },
74
+ }
75
+ }
76
+
77
+ /** 改配置。逐项做类型与范围收敛——UI 传什么进来都不该写出一份坏配置。 */
78
+ export async function setConfig(patch) {
79
+ await this.load()
80
+ const p = patch && typeof patch === 'object' ? patch : {}
81
+ const c = { ...this.state.config }
82
+ if (typeof p.enabled === 'boolean') c.enabled = p.enabled
83
+ if (p.trigger === 'auto' || p.trigger === 'manual') c.trigger = p.trigger
84
+ if (typeof p.apply === 'boolean') c.apply = p.apply
85
+ if (p.source === 'memory' || p.source === 'memory+sessions') c.source = p.source
86
+ if (p.rollbackScope === 'files' || p.rollbackScope === 'all') c.rollbackScope = p.rollbackScope
87
+ // 归档策略三档:与上面 trigger / rollbackScope 同一套写法 —— 非法值不写入,保留原值。
88
+ if (p.autoArchive === 'off' || p.autoArchive === 'report' || p.autoArchive === 'auto') c.autoArchive = p.autoArchive
89
+ if (p.minHours !== undefined) c.minHours = clampNum(Number(p.minHours), 1, 720, DEFAULT_CONFIG.minHours)
90
+ if (p.minSessions !== undefined) c.minSessions = Math.round(clampNum(Number(p.minSessions), 1, 200, DEFAULT_CONFIG.minSessions))
91
+ if (p.maxSteps !== undefined) c.maxSteps = Math.round(clampNum(Number(p.maxSteps), 1, 80, DEFAULT_CONFIG.maxSteps))
92
+ if (p.maxSnapshotKeep !== undefined)
93
+ c.maxSnapshotKeep = Math.round(clampNum(Number(p.maxSnapshotKeep), 1, 50, DEFAULT_CONFIG.maxSnapshotKeep))
94
+ if (typeof p.provider === 'string') c.provider = p.provider.trim().slice(0, 120)
95
+ if (typeof p.model === 'string') c.model = p.model.trim().slice(0, 160)
96
+ this.state.config = c
97
+ await this.persist()
98
+ return await this.getConfig()
99
+ }
100
+
101
+ /** 当前会话默认模型路线(取不到就是 null)。 */
102
+ export function defaultSelection() {
103
+ try {
104
+ const sel = this.svc('agentDefaultModel')?.currentSelection?.()
105
+ const provider = String(sel?.provider ?? '').trim()
106
+ const model = String(sel?.model ?? '').trim()
107
+ if (!provider || !model) return null
108
+ return { provider, model }
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 解析这一趟该用哪条模型路线。
116
+ *
117
+ * 三条语义必须分得清(否则「我明明选了模型它怎么还用别的」这类问题查不出来):
118
+ * - `source:'config'` 用户显式指定 → **指定了就只认它**,解析不出来宁可报错也不回落
119
+ * - `source:'default'` 配置留空 → 用当前会话默认模型
120
+ * - `source:'none'` 两条都拿不到 → `error` 里写清原因
121
+ * 永远返回对象,不抛异常;UI 两种形状都要能读(见 contract)。
122
+ */
123
+ export function resolveRoute(config) {
124
+ const p = String(config?.provider ?? '').trim()
125
+ const m = String(config?.model ?? '').trim()
126
+ const explicit = !!(p && m)
127
+ // 只填了一半:这是配置错误,不是「跟随默认」。说清楚,别猜。
128
+ if (!explicit && (p || m)) {
129
+ return {
130
+ provider: p,
131
+ model: m,
132
+ fromDefault: false,
133
+ source: 'none',
134
+ error: `配置里只填了 ${p ? 'provider' : 'model'},必须成对填;两项都清空则跟随当前会话默认模型`,
135
+ }
136
+ }
137
+ if (explicit) return { provider: p, model: m, fromDefault: false, source: 'config', error: null }
138
+ const def = this.defaultSelection()
139
+ if (def) return { provider: def.provider, model: def.model, fromDefault: true, source: 'default', error: null }
140
+ return {
141
+ provider: '',
142
+ model: '',
143
+ fromDefault: false,
144
+ source: 'none',
145
+ error: '配置里没写 provider/model,也拿不到当前会话默认模型',
146
+ }
147
+ }
148
+
149
+ /**
150
+ * 可选的模型路线清单。
151
+ *
152
+ * 数据源是宿主的 **`llm` 服务**(`listProviders()` + 逐 provider 的 `listModels()`),
153
+ * 三个「看起来更顺手但会出错」的替代都被排除了:
154
+ * - `agentDefaultModel` 只有当前那一条,不是目录;
155
+ * - 面向子代理的模型选择策略会**按白名单过滤 provider**,拿它当全量目录会漏;
156
+ * - `sessionController.modelCatalog()` 依赖会话控制器挂载,而整理是后台任务。
157
+ *
158
+ * 单个 provider 取模型失败**不拖垮整表**(宿主自己的目录也是这么处理的)——
159
+ * 一条渠道的 key 过期不该让整个下拉框变空。
160
+ */
161
+ export async function listModels() {
162
+ const routes = []
163
+ let catalogAvailable = false
164
+ let note = ''
165
+ const llm = this.svc('llm')
166
+ if (!llm || typeof llm.listProviders !== 'function') {
167
+ note = 'llm 服务不可用,取不到模型目录,请手填 provider 与 model'
168
+ } else {
169
+ let providers = []
170
+ try {
171
+ providers = llm.listProviders() ?? []
172
+ } catch (err) {
173
+ note = `读取 provider 列表失败:${err?.message ?? err}`
174
+ }
175
+ for (const p of providers) {
176
+ const pid = String(p?.id ?? '').trim()
177
+ if (!pid) continue
178
+ const pName = String(p?.name ?? pid).trim()
179
+ let models = []
180
+ try {
181
+ // 同步返回数组或 Promise 都收(不同宿主版本两种都有)。
182
+ models = (await llm.listModels(pid)) ?? []
183
+ } catch {
184
+ // 单个 provider 挂了不记 note:那是常态,不是整表故障。
185
+ models = []
186
+ }
187
+ for (const m of models) {
188
+ const mid = String(m?.id ?? '').trim()
189
+ if (!mid) continue
190
+ const mName = String(m?.name ?? mid).trim()
191
+ routes.push({ provider: pid, model: mid, label: `${pName} / ${mName}`, isDefault: false })
192
+ }
193
+ }
194
+ catalogAvailable = routes.length > 0
195
+ }
196
+ const def = this.defaultSelection()
197
+ if (def) {
198
+ const hit = routes.find((r) => r.provider === def.provider && r.model === def.model)
199
+ if (hit) hit.isDefault = true
200
+ else {
201
+ // 当前默认模型可能来自一个 listModels 拿不到东西的 provider(见下面「空目录放行」)。
202
+ // 把它补进来,否则下拉框会显示「跟随默认」但目录里找不到它。
203
+ routes.unshift({
204
+ provider: def.provider,
205
+ model: def.model,
206
+ label: `${def.provider} / ${def.model}`,
207
+ isDefault: true,
208
+ })
209
+ catalogAvailable = true
210
+ }
211
+ }
212
+ if (!catalogAvailable && !note) note = '宿主没有可枚举的模型目录,请手填 provider 与 model'
213
+ routes.sort((a, b) => `${a.provider}/${a.model}`.localeCompare(`${b.provider}/${b.model}`))
214
+ return { routes, defaultRoute: def, catalogAvailable, note }
215
+ }
216
+
217
+ /**
218
+ * 运行前判「这条路线在宿主里存在吗」。
219
+ *
220
+ * 运行前先做一次**纯本地的存在性校验**(只比对宿主已注册的 provider / model 目录,
221
+ * 不发任何模型请求)—— 这正是「运行前就报错,别跑到一半才失败」要的东西。
222
+ *
223
+ * 两个已知陷阱,都刻意避开:
224
+ * - **空目录放行**:某些适配器的 `listModels` 恒返回 `[]`。
225
+ * 若把「目录为空」当「模型不存在」,会把明明能用的路线毙掉 → 返回 `ok:true, checked:false`。
226
+ * - **不做「查模型元数据」式的存在性判断**:有的适配器对未知模型不报错,
227
+ * 而是合成一份假元数据(`name === id`),拿它校验等于永远通过。
228
+ *
229
+ * @returns {Promise<{ok:boolean, checked:boolean, reason:string}>}
230
+ */
231
+ export async function checkRouteAvailable(provider, model) {
232
+ const llm = this.svc('llm')
233
+ if (!llm || typeof llm.listProviders !== 'function') {
234
+ return { ok: true, checked: false, reason: 'llm 服务不可用,跳过存在性校验' }
235
+ }
236
+ let providers = []
237
+ try {
238
+ providers = llm.listProviders() ?? []
239
+ } catch (err) {
240
+ return { ok: true, checked: false, reason: `读取 provider 列表失败(${err?.message ?? err}),跳过校验` }
241
+ }
242
+ const ids = providers.map((p) => String(p?.id ?? '')).filter(Boolean)
243
+ if (!ids.includes(provider)) {
244
+ return { ok: false, checked: true, reason: `宿主没有名为 ${provider} 的 provider(已注册:${ids.join('、') || '(无)'})` }
245
+ }
246
+ let models = []
247
+ try {
248
+ models = (await llm.listModels(provider)) ?? []
249
+ } catch (err) {
250
+ return { ok: false, checked: true, reason: `读取 ${provider} 的模型目录失败:${err?.message ?? err}` }
251
+ }
252
+ if (!models.length) {
253
+ return { ok: true, checked: false, reason: `${provider} 的模型目录为空,无法判断该模型是否存在(放行)` }
254
+ }
255
+ if (!models.some((m) => String(m?.id ?? '') === model)) {
256
+ const names = models.map((m) => String(m?.id ?? '')).filter(Boolean)
257
+ return {
258
+ ok: false,
259
+ checked: true,
260
+ reason: `${provider} 的目录里没有模型 ${model}(可选:${names.slice(0, 8).join('、')}${names.length > 8 ? '…' : ''})`,
261
+ }
262
+ }
263
+ return { ok: true, checked: true, reason: '' }
264
+ }
265
+
266
+ /** 当前状态快照,供设置面板轮询。 */
267
+ export async function status() {
268
+ await this.load()
269
+ const cfg = this.state.config
270
+ const lastAt = this.state.lastRunAt
271
+ const hoursSince = lastAt ? (Date.now() - lastAt) / 3600000 : null
272
+ return {
273
+ running: this.running,
274
+ phase: this.phase,
275
+ startedAt: this.startedAt,
276
+ runId: this.runId,
277
+ steps: this.stepLog,
278
+ config: { ...cfg },
279
+ route: this.resolveRoute(cfg),
280
+ lastRunAt: lastAt,
281
+ hoursSince,
282
+ lastResult: this.state.lastResult,
283
+ retryAfter: this.state.retryAfter,
284
+ gate: cfg.enabled && cfg.trigger === 'auto' ? { minHours: cfg.minHours, minSessions: cfg.minSessions } : null,
285
+ paths: { memoryDir: this.memoryDir, configPath: this.configPath, stateRoot: this.stateRoot, home: this.home },
286
+ }
287
+ }
288
+
289
+ // ────────────────────────────── 门控 ──────────────────────────────
@@ -0,0 +1,142 @@
1
+ /**
2
+ * autodream —— 回滚点(快照):建立、修剪、定位、列出(引擎方法体)。
3
+ *
4
+ * 本文件里的函数都是 `AutodreamEngine` 的方法体:`engine.js` 里保留同名同签名的方法,
5
+ * 用 `xxxMod.name.call(this, …)` 转发进来 —— 所以函数体里的 `this` 就是引擎实例,
6
+ * 与拆分前完全一致,没有引入任何模块级可变状态。
7
+ */
8
+
9
+ import { DEFAULT_CONFIG, PRE_ROLLBACK_KEEP } from './config.js'
10
+ import { readJson, safeName, snapshotEntries, stampHuman } from './util.js'
11
+ import { createHash } from 'node:crypto'
12
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
13
+ import { join } from 'node:path'
14
+
15
+ /**
16
+ * 把 memory 目录顶层的 markdown 整体快照一份,作为**回滚点**。
17
+ *
18
+ * 只快照顶层 `.md`:`archive/` 子目录是退役区(不需要每次跟着复制),
19
+ * `autodream/` 是报告区(自己就是产物)。
20
+ *
21
+ * 每条都记 sha256:回滚后「是不是真的回到了那一版」靠它判等——只看体积在 markdown 里太容易骗人。
22
+ *
23
+ * @param {string} id — 快照 id(apply 运行就是 runId;保护快照用 `pre-rollback-<时间戳>`)
24
+ * @param {'run'|'pre-rollback'} kind
25
+ * @returns {Promise<{id:string, dir:string, count:number, expected:number, failed:string[], files:Array<{name:string,bytes:number,sha256:string}>}>}
26
+ */
27
+ export async function snapshot(id, kind = 'run') {
28
+ const dir = join(this.snapshotRoot, id)
29
+ await mkdir(this.snapshotRoot, { recursive: true })
30
+ // **故意不用 recursive**:快照 id 撞车必须是硬错误,不能静默覆盖一份已有的回滚点。
31
+ // 实测踩到 —— 同一秒里连着两次回滚,`pre-rollback-<秒级时间戳>` 撞车,
32
+ // 第二次的保护快照把第一次的覆盖成「回滚后」的状态,于是「退回到保护点」等于什么都没退。
33
+ try {
34
+ await mkdir(dir)
35
+ } catch (err) {
36
+ if (err?.code === 'EEXIST') throw new Error(`回滚点 id 撞车:${id} 已存在,拒绝覆盖已有的回滚点`)
37
+ throw err
38
+ }
39
+ const entries = await readdir(this.memoryDir, { withFileTypes: true }).catch(() => [])
40
+ const files = entries.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
41
+ const recorded = []
42
+ /** 没存下来的文件。**不是「警告一下就继续」**:调用方要拿它决定是否中止。 */
43
+ const failed = []
44
+ for (const f of files) {
45
+ try {
46
+ const buf = await readFile(join(this.memoryDir, f.name))
47
+ await writeFile(join(dir, f.name), buf)
48
+ recorded.push({ name: f.name, bytes: buf.length, sha256: createHash('sha256').update(buf).digest('hex') })
49
+ } catch (err) {
50
+ failed.push(f.name)
51
+ this.log('warn', `快照 ${f.name} 失败:${err?.message ?? err}`)
52
+ }
53
+ }
54
+ const manifest = {
55
+ schema: 1,
56
+ id,
57
+ kind,
58
+ runId: kind === 'run' ? id : null,
59
+ createdAt: Date.now(),
60
+ createdAtHuman: stampHuman(Date.now()),
61
+ files: recorded,
62
+ fileCount: recorded.length,
63
+ failed,
64
+ }
65
+ await writeFile(join(dir, 'MANIFEST.json'), JSON.stringify(manifest, null, 2), 'utf8')
66
+ await this.pruneSnapshots()
67
+ return { id, dir, count: recorded.length, expected: files.length, failed, files: recorded }
68
+ }
69
+
70
+ /**
71
+ * 淘汰旧回滚点。
72
+ *
73
+ * **两类分开数**:普通运行的快照按 `maxSnapshotKeep`;「回滚前保护快照」
74
+ * 另给一个额度(`PRE_ROLLBACK_KEEP`)—— 保护快照的唯一用途就是「刚回滚完发现滚错了」,
75
+ * 被普通运行的快照轮转挤掉等于没有。旧版目录(`<stateRoot>/snapshots/`)只读,不参与淘汰。
76
+ */
77
+ export async function pruneSnapshots() {
78
+ const keep = Math.max(1, this.state.config.maxSnapshotKeep || DEFAULT_CONFIG.maxSnapshotKeep)
79
+ const entries = await readdir(this.snapshotRoot, { withFileTypes: true }).catch(() => [])
80
+ const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort()
81
+ const preRollback = dirs.filter((n) => n.startsWith('pre-rollback-'))
82
+ const runs = dirs.filter((n) => !n.startsWith('pre-rollback-'))
83
+ for (const name of runs.slice(0, Math.max(0, runs.length - keep))) {
84
+ await rm(join(this.snapshotRoot, name), { recursive: true, force: true }).catch(() => {})
85
+ }
86
+ for (const name of preRollback.slice(0, Math.max(0, preRollback.length - PRE_ROLLBACK_KEEP))) {
87
+ await rm(join(this.snapshotRoot, name), { recursive: true, force: true }).catch(() => {})
88
+ }
89
+ }
90
+
91
+ /** 快照目录的真实位置:先找新版,再找旧版;都没有返回 null。 */
92
+ export async function resolveSnapshotDir(id) {
93
+ const name = safeName(id)
94
+ if (!name) return null
95
+ for (const root of [this.snapshotRoot, this.legacySnapshotRoot]) {
96
+ const dir = join(root, name)
97
+ const info = await stat(dir).catch(() => null)
98
+ if (info?.isDirectory()) return dir
99
+ }
100
+ return null
101
+ }
102
+
103
+ /**
104
+ * 列出所有回滚点(新版 + 旧版目录都列)。
105
+ *
106
+ * 「可回退到哪一份」是给人看的,所以:旧版快照标 `legacy:true`、保护快照标 `kind`,
107
+ * 让界面能说清「这一份是哪来的」。
108
+ */
109
+ export async function listSnapshots() {
110
+ const out = []
111
+ for (const [root, legacy] of [
112
+ [this.snapshotRoot, false],
113
+ [this.legacySnapshotRoot, true],
114
+ ]) {
115
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => [])
116
+ for (const e of entries) {
117
+ if (!e.isDirectory()) continue
118
+ const dir = join(root, e.name)
119
+ const manifest = await readJson(join(dir, 'MANIFEST.json'))
120
+ const names = await snapshotEntries(dir)
121
+ out.push({
122
+ name: e.name,
123
+ at: manifest?.createdAt ?? 0,
124
+ atHuman: manifest?.createdAtHuman ?? e.name,
125
+ files: Array.isArray(manifest?.files)
126
+ ? manifest.files.length
127
+ : typeof manifest?.files === 'number'
128
+ ? manifest.files
129
+ : names.length,
130
+ path: dir,
131
+ runId: manifest?.runId ?? (manifest?.kind === 'run' ? e.name : null),
132
+ kind: manifest?.kind ?? (legacy ? 'legacy' : 'run'),
133
+ legacy,
134
+ })
135
+ }
136
+ }
137
+ // 新的在前(名字里带时间戳,字典序即时间序)。
138
+ out.sort((a, b) => b.name.localeCompare(a.name))
139
+ return out
140
+ }
141
+
142
+ // ────────────────────────────── LLM 循环 ──────────────────────────────