dsh-recall-plugin 2.0.0 → 2.1.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,162 @@
1
+ /**
2
+ * dsh-recall-plugin — 核心路由域(R2 从 index.js 拆出)
3
+ *
4
+ * init / snapshot-info / preview / execute / status / lineage-record 六个核心
5
+ * 端点。依赖经 deps 注入(rt/snaps/state/cfg/enqueue/agentBusy/rescueRollback
6
+ * 等),无模块级可变状态(HMR 假设)。preview/execute 内部跑 git add -A,
7
+ * 经 enqueue 排进与快照/gc 同一条串行队列,避免 index.lock 竞态。
8
+ */
9
+
10
+ import { ENV_HINTS } from './diagnostics.js'
11
+
12
+ export function createRoutesCore(deps) {
13
+ // ctx 不解构(A4):本域所有服务访问都已由 rt/snaps 封装,直接摸 ctx 会
14
+ // 绕过工厂分层——留空位只会诱导未来代码破坏依赖注入约定
15
+ const { rt, snaps, state, cfg, supported, enqueue, agentBusy, rescueRollback, E } = deps
16
+
17
+ return {
18
+ 'init': async (args) => {
19
+ if (!supported) {
20
+ return { ok: false, root: null, notice: { unsupported: true } }
21
+ }
22
+ const sessionId = args && args.sessionId ? String(args.sessionId) : null
23
+ const root = await rt.resolveRoot(sessionId)
24
+ let notice = null
25
+ if (root) {
26
+ let store = await rt.resolveStore(root)
27
+ store = await rt.tryUpgradeToHome(root)
28
+ await rt.ensureGit(root, store)
29
+ await snaps.loadIndex(root, sessionId)
30
+ await snaps.rebuildOrphans(root, sessionId)
31
+ rt.cleanupLegacy(root)
32
+ // 降级状态随 init 下发,Client 弹一次性提示(每次页面加载各弹一次):
33
+ // gitMissing=未检测到 git CLI(撤回按钮不出现);homeFallback=home
34
+ // 不可写,快照降级存进项目内 .dsh-recall-snapshots。
35
+ notice = {
36
+ gitMissing: state.gitExe === '',
37
+ homeFallback: store ? !store.home : false
38
+ }
39
+ }
40
+ // 顺带下发客户端行为开关(fillDraft 等):Client 无须为读配置单开请求,
41
+ // init 是每会话必经的预热通道
42
+ return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft, archiveOriginal: cfg.archiveOriginal } }
43
+ },
44
+
45
+ 'snapshot-info': async (args) => {
46
+ const id = args && args.messageId ? String(args.messageId) : ''
47
+ const snap = state.snapshots.get(id)
48
+ // 失败/跳过/熔断反馈(issue #7 失败可见性):客户端轮询到 failed 即
49
+ // 终止轮询并 toast,不再空等 20 次;has 时附带 skipped 让用户知道
50
+ // fail-open 跳过了哪些路径
51
+ const feedback = await snaps.feedbackFor(args && args.sessionId, id)
52
+ return { has: Boolean(snap), time: snap ? snap.time : null, id, ...feedback }
53
+ },
54
+
55
+ 'preview': async (args) => {
56
+ const id = args && args.messageId ? String(args.messageId) : ''
57
+ const sessionId = args && args.sessionId ? String(args.sessionId) : null
58
+ // P0-1:目标工作区 agent 运行中直接拒绝预览(避免用户确认时文件被
59
+ // agent 改动,预览清单与实际回退内容脱节)。同会话优先命中(最常见
60
+ // 场景),快照存在时叠加跨会话同工作区检查。
61
+ const snap = state.snapshots.get(id)
62
+ if (agentBusy(sessionId, snap ? snap.root : null)) return { ok: false, code: E.RECALL_AGENT_BUSY, message: 'Agent 正在运行中,请先停止后再撤回' }
63
+ const result = await enqueue(() => snaps.diffFor(id))
64
+ if (result === null) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: '该消息没有可用的项目快照' }
65
+ const snap2 = state.snapshots.get(id)
66
+ const cutSeq = await snaps.resolveCutSeq(sessionId, id)
67
+ return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq }
68
+ },
69
+
70
+ 'execute': async (args) => {
71
+ const id = args && args.messageId ? String(args.messageId) : ''
72
+ const sessionId = args && args.sessionId ? String(args.sessionId) : null
73
+ const result = await enqueue(async () => {
74
+ const snap = state.snapshots.get(id)
75
+ if (!snap) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: '该消息没有可用的项目快照' }
76
+ const store = state.stores.get(snap.root)
77
+ if (!store) return { ok: false, code: E.RECALL_NO_STORE, message: '快照存储不可用' }
78
+ // P0-1:队列内第一步——执行前再查一次 agent 状态。检查放在互斥
79
+ // 队列内,检查后紧接执行,中间不可能插进别的操作,窗口为零。
80
+ if (agentBusy(sessionId, snap.root)) return { ok: false, code: E.RECALL_AGENT_BUSY, message: 'Agent 正在运行中,请先停止后再撤回' }
81
+ // P0-3:preview→execute 失效校验。只由带 previewTotal 的新版
82
+ // Client 触发(老版本/直调 API 不带则跳过,向后兼容)。校验失败
83
+ // 连安全快照都不打——省一次全量 add。同数不同文件的边缘情形由
84
+ // 下方 pre-rollback 安全快照兜底。
85
+ if (args && typeof args.previewTotal === 'number') {
86
+ const fresh = await snaps.diffFor(id)
87
+ if (!fresh || fresh.total !== args.previewTotal) {
88
+ return { ok: false, code: E.RECALL_STALE, message: '预览后项目文件发生了变化,请重新预览确认' }
89
+ }
90
+ }
91
+ // 回退前自动打安全快照:回退覆盖工作区且不回写 index(旧的
92
+ // 「当前状态」从此无任何快照可找回),用消息 ID 打 tag 会与该消息
93
+ // 的既有快照碰撞,故用独立前缀的时间戳 tag——不进 index.json
94
+ // (列表不展示),但孤儿重建/手动 git tag 仍能找到它,误回退后
95
+ // 用户可让插件从该 tag 恢复,堵住唯一的不可逆操作缺口。
96
+ // 失败时 safetyOk 置 false:后续回退若也失败将无救援点(H1),
97
+ // 行为退化为现状(fail-loud),不更差。
98
+ const safetyId = 'pre-rollback-' + Date.now()
99
+ let safetyOk = false
100
+ try {
101
+ await rt.runShell(rt.scripts.snapshotScript(snap.root, store, state.gitExe, safetyId, cfg.baseExcludes), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
102
+ safetyOk = true
103
+ } catch (error) {
104
+ // 安全快照失败不阻断回退本身:用户已确认覆盖,记录后照原计划执行
105
+ rt.recordError('recall safety snapshot failed: ' + String(error))
106
+ }
107
+ const rolled = await snaps.rollbackFor(id)
108
+ if (rolled.ok) return rolled
109
+ // 回退失败(rollbackFor 返回 partial,工作区可能半回退):用安全快照
110
+ // 救援(H1)。rescueRollback 是 snapshots.js 模块级纯逻辑,副作用经
111
+ // deps 注入,三分支(无救援点/救援成功/救援失败)单测直接钉。
112
+ return rescueRollback(
113
+ { runShell: rt.runShell, scripts: rt.scripts, gitExe: state.gitExe, recordError: rt.recordError },
114
+ { root: snap.root, store, safetyId, safetyOk, rollbackError: rolled.error }
115
+ )
116
+ })
117
+ if (!result.ok) return result
118
+ // 文件回退后再解析切点:切点只依赖会话日志,与快照是否删除无关(命中缓存,瞬时)
119
+ const cutSeq = await snaps.resolveCutSeq(sessionId, id)
120
+ return { ok: true, count: result.count, cutSeq }
121
+ },
122
+
123
+ // 设置页排障:最近错误(Host 侧 console.error 的页面可见副本)。
124
+ // M1-D3/D5:条目自带 count/kind(recordError 富集)——count 在服务端
125
+ // 拼成「(×N)」展示文本,设置页按 message 渲染即显示重复计数,零
126
+ // Client 改动;hint 是分类后的可行动提示(API 自描述,本次无客户端
127
+ // 消费,设置页未来展示零成本)。storeBase(M2-D3)暴露快照存储根,
128
+ // 供设置页未来展示「快照存在哪里」,失败为 null。
129
+ 'status': async (args) => {
130
+ const storeBase = await rt.resolveHomeContainer()
131
+ if (args && args.op === 'clear') {
132
+ state.errors.length = 0
133
+ return { ok: true, errors: [], storeBase }
134
+ }
135
+ const errors = state.errors.slice(-20).reverse().map((e) => ({
136
+ ...e,
137
+ message: e.message + (e.count > 1 ? '(×' + e.count + ')' : ''),
138
+ hint: ENV_HINTS[e.kind] || null
139
+ }))
140
+ return { ok: true, errors, storeBase }
141
+ },
142
+
143
+ // F1:client fork 成功后上报撤回链(childId ↔ parentId),Host 持久化到
144
+ // lineage.json 供快照管理树聚族展示「版本家族」。root 优先按 fork 源
145
+ // parentId 解析(fork 时它仍是 live 会话;归档只隐藏列表、对象在内存),
146
+ // 失败回退 childId。
147
+ 'lineage-record': async (args) => {
148
+ const childId = args && args.childId ? String(args.childId) : ''
149
+ const parentId = args && args.parentId ? String(args.parentId) : ''
150
+ if (!childId || !parentId) return { ok: false, code: E.RECALL_BAD_TYPE, message: '缺少会话 ID' }
151
+ const root = (await rt.resolveRoot(parentId)) || (await rt.resolveRoot(childId))
152
+ if (!root) return { ok: false, code: E.RECALL_NO_ROOT, message: '无法解析工作区' }
153
+ let store = state.stores.get(root)
154
+ if (!store) {
155
+ try { store = await rt.resolveStore(root) } catch (error) { store = null }
156
+ }
157
+ if (!store) return { ok: false, code: E.RECALL_NO_STORE, message: '快照存储不可用' }
158
+ await snaps.recordLineage(root, childId, parentId)
159
+ return { ok: true }
160
+ }
161
+ }
162
+ }