dsh-recall-plugin 2.3.0 → 2.3.2

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