dsh-vscode-mode 0.14.2 → 0.14.3

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.
package/lib/index.js CHANGED
@@ -5,6 +5,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync,
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { homedir, tmpdir } from "node:os";
8
+ import { DatabaseSync } from "node:sqlite";
8
9
  import { Buffer as Buffer$1 } from "node:buffer";
9
10
  import { execFile, spawn, spawnSync } from "node:child_process";
10
11
  import z from "@deepseek-ai/schemastery";
@@ -2241,8 +2242,6 @@ const DSH_HOME_ENV = "DSH_HOME";
2241
2242
  const CACHE_TOTAL_CAP = 209715200;
2242
2243
  /** 工作区根侧车:审查记录(用户数据,随项目走,不迁移)。 */
2243
2244
  const SIDECAR = ".dsh-edit-review.json";
2244
- /** 工作区根侧车:归档(用户数据)。 */
2245
- const SIDECAR_ARCHIVE = ".dsh-edit-review-archive.json";
2246
2245
  /** 旧 debug 日志文件名(工作区,迁移后由插件清理)。 */
2247
2246
  const DEBUG_LOG = ".dsh-edit-review-debug.log";
2248
2247
  /** 旧树缓存 sidecar 名(工作区,迁移后由插件清理;缓存可重建,删除无损)。 */
@@ -2376,6 +2375,16 @@ function uiGlobalsFile(home = dshHome(), schema = 1) {
2376
2375
  function debugLogFile(cwd, home = dshHome()) {
2377
2376
  return join(pluginLogRoot(home), "debug." + hashOf(cwd) + ".log");
2378
2377
  }
2378
+ /**
2379
+ * 每工作区归档数据库(用户数据,不受缓存清理预算约束)。
2380
+ * @author ddj 2026年09月30号
2381
+ * @param cwd 工作区绝对路径
2382
+ * @param home DSH home
2383
+ * @returns 归档数据库绝对路径
2384
+ */
2385
+ function archiveDbFile(cwd, home = dshHome()) {
2386
+ return join(home, PLUGIN_ID, "data", "archive", hashOf(cwd) + ".sqlite");
2387
+ }
2379
2388
  /** 插件包 assets 目录(import.meta.url 派生;config.imageDir 可覆盖,见 imageDirOf)。 */
2380
2389
  function assetsDirOf(moduleUrl) {
2381
2390
  return join(dirname(fileURLToPath(moduleUrl)), "..", "assets");
@@ -3735,21 +3744,42 @@ function commonSuffix(left, right, prefix) {
3735
3744
  while (i < limit && left.charCodeAt(left.length - 1 - i) === right.charCodeAt(right.length - 1 - i)) i++;
3736
3745
  return i;
3737
3746
  }
3738
- /** 找出文本的全部非重叠出现位置。 */
3739
- function allMatches(content, needle) {
3740
- if (!needle.length) return [];
3741
- const out = [];
3742
- let from = 0;
3743
- while (from <= content.length - needle.length) {
3744
- const at = content.indexOf(needle, from);
3745
- if (at < 0) break;
3746
- out.push({
3747
+ /**
3748
+ * 懒扫描候选:先找 cursor 后未占用项,找不到再回退至首个未占用项。
3749
+ * @author ddj 2026年09月29号
3750
+ * @param content 当前内容
3751
+ * @param needle 搜索串
3752
+ * @param cursor 前一块的结束位置
3753
+ * @param occupied 已选区间
3754
+ * @param state 此搜索串已扫描的候选
3755
+ * @returns 与完整候选列表双轮查找相同的匹配
3756
+ */
3757
+ function findCandidate(content, needle, cursor, occupied, state) {
3758
+ if (!needle.length) return void 0;
3759
+ let fallback;
3760
+ for (let i = 0; i < state.ranges.length; i++) {
3761
+ const range = state.ranges[i];
3762
+ if (occupied.some((used) => overlaps(range, used))) continue;
3763
+ if (range.start >= cursor) return range;
3764
+ if (!fallback) fallback = range;
3765
+ }
3766
+ while (!state.done) {
3767
+ const at = content.indexOf(needle, state.next);
3768
+ if (at < 0) {
3769
+ state.done = true;
3770
+ break;
3771
+ }
3772
+ const range = {
3747
3773
  start: at,
3748
3774
  end: at + needle.length
3749
- });
3750
- from = at + Math.max(1, needle.length);
3775
+ };
3776
+ state.ranges.push(range);
3777
+ state.next = range.end;
3778
+ if (occupied.some((used) => overlaps(range, used))) continue;
3779
+ if (range.start >= cursor) return range;
3780
+ if (!fallback) fallback = range;
3751
3781
  }
3752
- return out;
3782
+ return fallback;
3753
3783
  }
3754
3784
  /**
3755
3785
  * 从 after 快照定位所有 hunk,按 hunk 顺序优先选未占用且靠近前一块的候选。
@@ -3763,6 +3793,7 @@ function locateHunks(content, hunks) {
3763
3793
  const locations = [];
3764
3794
  let cursor = 0;
3765
3795
  const occupied = [];
3796
+ const matches = /* @__PURE__ */ new Map();
3766
3797
  for (let idx = 0; idx < hunks.length; idx++) {
3767
3798
  const hunk = hunks[idx];
3768
3799
  const newText = hunk.newText;
@@ -3771,8 +3802,21 @@ function locateHunks(content, hunks) {
3771
3802
  end: hunk.afterEnd
3772
3803
  } : null;
3773
3804
  const snapshotMatches = snapshot && snapshot.start >= 0 && snapshot.end >= snapshot.start && (newText.length === 0 || content.slice(snapshot.start, snapshot.end) === newText) ? snapshot : null;
3774
- const candidates = snapshotMatches ? [snapshotMatches] : allMatches(content, newText);
3775
- const candidate = candidates.find((range) => range.start >= cursor && !occupied.some((used) => overlaps(range, used))) ?? candidates.find((range) => !occupied.some((used) => overlaps(range, used)));
3805
+ let candidate;
3806
+ if (snapshotMatches) {
3807
+ if (!occupied.some((used) => overlaps(snapshotMatches, used))) candidate = snapshotMatches;
3808
+ } else if (newText.length) {
3809
+ let state = matches.get(newText);
3810
+ if (!state) {
3811
+ state = {
3812
+ ranges: [],
3813
+ next: 0,
3814
+ done: false
3815
+ };
3816
+ matches.set(newText, state);
3817
+ }
3818
+ candidate = findCandidate(content, newText, cursor, occupied, state);
3819
+ }
3776
3820
  if (candidate) {
3777
3821
  locations.push({
3778
3822
  idx,
@@ -3832,82 +3876,43 @@ function replaceRange(content, location, reverse = false) {
3832
3876
  const replacement = reverse ? location.hunk.oldText ?? "" : location.hunk.newText;
3833
3877
  return content.slice(0, location.start) + replacement + content.slice(location.end);
3834
3878
  }
3835
- /** 按当前内容中出现的位置从后向前应用 hunk,避免前块长度变化影响后块。 */
3879
+ /**
3880
+ * 按位置倒序收集片段,最后一次拼接;非互斥区间退回原替换语义。
3881
+ * @author ddj 2026年09月29号
3882
+ * @param content 当前文件内容
3883
+ * @param locations 已定位区间
3884
+ * @param reverse 是否反向应用
3885
+ * @returns 合成内容及失效块索引
3886
+ */
3836
3887
  function applyLocations(content, locations, reverse = false) {
3837
3888
  const stale = locations.filter((location) => !location.matched).map((location) => location.idx);
3838
- let out = content;
3839
3889
  const matched = locations.filter((location) => location.matched).sort((a, b) => b.start - a.start);
3890
+ const parts = [];
3891
+ let end = content.length;
3840
3892
  for (const location of matched) {
3841
- const next = replaceRange(out, location, reverse);
3842
- if (next === null) stale.push(location.idx);
3843
- else out = next;
3893
+ if (location.start < 0 || location.end > end) {
3894
+ let out = content;
3895
+ for (const item of matched) {
3896
+ const next = replaceRange(out, item, reverse);
3897
+ if (next === null) stale.push(item.idx);
3898
+ else out = next;
3899
+ }
3900
+ return {
3901
+ content: out,
3902
+ stale: stale.sort((a, b) => a - b)
3903
+ };
3904
+ }
3905
+ parts.push(content.slice(location.end, end), reverse ? location.hunk.oldText ?? "" : location.hunk.newText);
3906
+ end = location.start;
3844
3907
  }
3908
+ parts.push(content.slice(0, end));
3909
+ parts.reverse();
3845
3910
  return {
3846
- content: out,
3911
+ content: parts.join(""),
3847
3912
  stale: stale.sort((a, b) => a - b)
3848
3913
  };
3849
3914
  }
3850
3915
  /**
3851
- * 归一化一条持久化记录(容忍旧版/损坏字段)。
3852
- * @author ddj 2026年08月20号
3853
- * @param raw 反序列化的原始记录
3854
- * @returns 合法记录或 null
3855
- */
3856
- function normalizeRecord(raw) {
3857
- if (!raw || typeof raw !== "object") return null;
3858
- const r = raw;
3859
- if (typeof r.callId !== "string" || typeof r.path !== "string") return null;
3860
- const hunks = Array.isArray(r.hunks) ? r.hunks.filter((h) => h && typeof h.newText === "string").map((h) => {
3861
- const item = h;
3862
- return {
3863
- oldText: typeof item.oldText === "string" ? item.oldText : null,
3864
- newText: item.newText,
3865
- ...Number.isInteger(item.afterStart) ? { afterStart: item.afterStart } : {},
3866
- ...Number.isInteger(item.afterEnd) ? { afterEnd: item.afterEnd } : {},
3867
- ...Number.isInteger(item.beforeStart) ? { beforeStart: item.beforeStart } : {},
3868
- ...Number.isInteger(item.beforeEnd) ? { beforeEnd: item.beforeEnd } : {}
3869
- };
3870
- }) : [];
3871
- const toolName = r.toolName === "write" || r.toolName === "remote_ssh_write" ? r.toolName : "edit";
3872
- const rawDecisions = r.decisions && typeof r.decisions === "object" ? r.decisions : {};
3873
- const call = rawDecisions.call === "accepted" || rawDecisions.call === "rejected" ? rawDecisions.call : "pending";
3874
- const rawPerHunk = Array.isArray(rawDecisions.perHunk) ? rawDecisions.perHunk : [];
3875
- const perHunk = hunks.map((_, i) => {
3876
- const value = rawPerHunk[i];
3877
- if (value === "accepted" || value === "rejected") return value;
3878
- return call === "accepted" || call === "rejected" ? call : "pending";
3879
- });
3880
- const before = typeof r.before === "string" ? r.before : null;
3881
- const hasAfter = Object.prototype.hasOwnProperty.call(r, "after");
3882
- const after = typeof r.after === "string" ? r.after : null;
3883
- return {
3884
- callId: r.callId,
3885
- toolName,
3886
- path: r.path,
3887
- before,
3888
- after,
3889
- baseFingerprint: typeof r.baseFingerprint === "string" ? r.baseFingerprint : fingerprint(before),
3890
- afterFingerprint: typeof r.afterFingerprint === "string" ? r.afterFingerprint : fingerprint(after),
3891
- legacy: !hasAfter,
3892
- conflict: r.conflict === true,
3893
- create: r.create === true,
3894
- callHunk: r.callHunk && typeof r.callHunk.oldText === "string" && typeof r.callHunk.newText === "string" ? {
3895
- oldText: r.callHunk.oldText,
3896
- newText: r.callHunk.newText
3897
- } : null,
3898
- hunks,
3899
- decisions: {
3900
- call,
3901
- perHunk
3902
- },
3903
- note: typeof r.note === "string" ? r.note : null,
3904
- superseded: r.superseded === true,
3905
- archived: r.archived === true,
3906
- batch: Number.isInteger(r.batch) ? r.batch : 0,
3907
- at: typeof r.at === "string" ? r.at : (/* @__PURE__ */ new Date()).toISOString()
3908
- };
3909
- }
3910
- /**
3911
3916
  * 写决策到记录(scope=call 整体 / hunk 单个块)。
3912
3917
  * @author ddj 2026年08月20号
3913
3918
  * @param record 记录
@@ -4012,16 +4017,6 @@ function prune(map, max = 200) {
4012
4017
  const sorted = [...map.values()].sort((a, b) => a.at < b.at ? -1 : 1);
4013
4018
  for (let i = 0; i < sorted.length - max; i++) map.delete(sorted[i].callId);
4014
4019
  }
4015
- /** 按批次分组记录。 */
4016
- function groupByBatch(recs) {
4017
- const map = /* @__PURE__ */ new Map();
4018
- for (const r of recs) {
4019
- const k = Number.isInteger(r.batch) ? r.batch : null;
4020
- if (!map.has(k)) map.set(k, []);
4021
- map.get(k).push(r);
4022
- }
4023
- return map;
4024
- }
4025
4020
  /**
4026
4021
  * 归档条目构造(一条批次一条)。
4027
4022
  * @author ddj 2026年08月20号
@@ -4060,105 +4055,742 @@ function archiveEntryFor(recs, cwd, reason) {
4060
4055
  };
4061
4056
  }
4062
4057
  //#endregion
4063
- //#region src/store.ts
4064
- const READ_CAP$1 = 8388608;
4065
- /** 二进制预览/写回上限(PDF 等整文件 base64 传输;文本侧仍用 READ_CAP)。 */
4066
- const BINARY_READ_CAP = 33554432;
4067
- /** 会话沙箱策略(policyOf):可能不存在,调用方需容忍 undefined。 */
4068
- function policyOf(ctx, session) {
4069
- const svc = ctx.get("sandboxPolicy");
4070
- if (!svc) return void 0;
4071
- return session ? svc.resolve({ session }) : svc.resolve();
4058
+ //#region src/debugLog.ts
4059
+ /**
4060
+ * dsh-vscode-mode host — 诊断文件通道(client 上报 → 缓冲批量落盘)+ 日志文件管理。
4061
+ * 通道:edrv.debug RPC → 内存缓冲(满/空闲触发)→ 串行读改写
4062
+ * `~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log`(512KB 上限截断保留尾部)。
4063
+ * 设计:console 不一定落盘,文件可靠;写失败静默忽略(调试日志不阻塞业务)。
4064
+ * 管理:edrv.dlog.* RPC 用的清单/尾部读取/清空三能力;file 参数一律先过
4065
+ * isDebugLogName 白名单(纯文件名、无路径分隔),杜绝穿越出日志根。
4066
+ * 作者 ddj 2026-09-08 / 2026-09-17
4067
+ */
4068
+ /** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
4069
+ const debugBuffers = /* @__PURE__ */ new Map();
4070
+ /** cwd → Promise 链:串行化 debug 日志落盘(fs read+write 非原子,避免并发丢行)。 */
4071
+ const debugWriteQueues = /* @__PURE__ */ new Map();
4072
+ /** debug 日志单文件上限:超限截断保留尾部,防文件无限增长拖慢每次追加。 */
4073
+ const DEBUG_LOG_CAP = 524288;
4074
+ /** debug 日志批量缓冲上限:攒满即落盘(一次读改写),上限内不逐条写文件。 */
4075
+ const DEBUG_BUF_CAP = 32768;
4076
+ /** debug 日志空闲 flush 延迟:缓冲未满时,静默一段时间后落盘一次。 */
4077
+ const DEBUG_FLUSH_IDLE_MS = 1e3;
4078
+ /** cwd → 已清理旧工作区 debug 日志标记(一次性)。 */
4079
+ const debugLegacyCleaned = /* @__PURE__ */ new Set();
4080
+ /** 诊断回显日志器(终端回显统一带 `[dsh-vscode-mode:debug]` 前缀,级别跟随上报级别)。 */
4081
+ const debugLog = log.child("debug");
4082
+ /** 诊断日志文件名白名单(cwd hash 命名;请求回传的 file 参数必须整名匹配,防路径穿越)。 */
4083
+ const DEBUG_LOG_NAME = /^debug\.[0-9a-f]{16}\.log$/;
4084
+ /**
4085
+ * 文件名是否为合法诊断日志名(白名单整名匹配,杜绝路径穿越)。
4086
+ * @author ddj 2026年09月17号
4087
+ * @param name 请求回传的文件名(可为空)
4088
+ * @returns 是否合法
4089
+ */
4090
+ function isDebugLogName(name) {
4091
+ return typeof name === "string" && DEBUG_LOG_NAME.test(name);
4072
4092
  }
4073
- /** 按会话 cwd 解析目标路径。 */
4074
- async function resolveTarget(ctx, session, path) {
4075
- const fs = ctx.get("fs");
4076
- const cwd = session?.header?.cwd;
4077
- return fs.resolve(path, cwd ? { cwd } : {});
4093
+ /**
4094
+ * 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
4095
+ * 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
4096
+ * @author ddj 2026年09月08号
4097
+ * @param cwd 工作区(日志落 ~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log)
4098
+ * @param batch 本批日志行
4099
+ */
4100
+ function flushDebug(cwd, batch) {
4101
+ const task = (debugWriteQueues.get(cwd) ?? Promise.resolve()).then(async () => {
4102
+ try {
4103
+ await mkdir(pluginLogRoot(), { recursive: true });
4104
+ const target = debugLogFile(cwd);
4105
+ const appended = await readFile(target, "utf8").catch(() => "") + batch.map((l) => (/* @__PURE__ */ new Date()).toISOString() + " " + l + "\n").join("");
4106
+ const next = appended.length > DEBUG_LOG_CAP ? appended.slice(-Math.floor(DEBUG_LOG_CAP / 2)) : appended;
4107
+ await writeFile(target, next, "utf8");
4108
+ if (!debugLegacyCleaned.has(cwd)) {
4109
+ debugLegacyCleaned.add(cwd);
4110
+ await rm(join(cwd, DEBUG_LOG), { force: true }).catch(() => {});
4111
+ }
4112
+ } catch (e) {}
4113
+ });
4114
+ debugWriteQueues.set(cwd, task);
4115
+ return task;
4078
4116
  }
4079
- /** 读取工作区主 sidecar 文本(缺失/失败 → null)。 */
4080
- async function readSidecarText(ctx, cwd) {
4081
- const fs = ctx.get("fs");
4082
- if (!fs || !cwd) return null;
4083
- try {
4084
- return await fs.readText(await fs.resolve(SIDECAR, { cwd }));
4085
- } catch (error) {
4086
- return null;
4117
+ /**
4118
+ * 记录一条 client 上报的诊断日志:入队缓冲批量落盘 + 终端回显(级别跟随,缺省 debug)。
4119
+ * 落盘行格式 `<iso> [level] text`;级别标记在入队时补齐,flush 侧无感。
4120
+ * @author ddj 2026年09月08号 / 2026年09月17号
4121
+ * @param _ctx DSH 上下文(入参保留:诊断通道按 cwd 隔离,当前不需要 ctx,未来过滤用)
4122
+ * @param cwd 工作区(日志文件按 cwd hash 隔离)
4123
+ * @param text 单条日志文本(不含换行)
4124
+ * @param level 日志级别(缺省 debug;落盘行与终端回显均带此级别)
4125
+ */
4126
+ function debugRecord(_ctx, cwd, text, level = "debug") {
4127
+ const tagged = "[" + level + "] " + text;
4128
+ const st = debugBuffers.get(cwd) ?? {
4129
+ lines: [],
4130
+ len: 0,
4131
+ timer: null
4132
+ };
4133
+ st.lines.push(tagged);
4134
+ st.len += tagged.length;
4135
+ const flush = () => {
4136
+ st.timer = null;
4137
+ const batch = st.lines;
4138
+ st.lines = [];
4139
+ st.len = 0;
4140
+ flushDebug(cwd, batch);
4141
+ };
4142
+ if (st.len >= DEBUG_BUF_CAP) {
4143
+ if (st.timer) clearTimeout(st.timer);
4144
+ flush();
4145
+ } else if (!st.timer) st.timer = setTimeout(flush, DEBUG_FLUSH_IDLE_MS);
4146
+ debugBuffers.set(cwd, st);
4147
+ debugLog[level](text);
4148
+ }
4149
+ /**
4150
+ * 清单:日志根下全部诊断日志文件(名字/字节/mtime;非法名跳过),mtime 新的在前。
4151
+ * @author ddj 2026年09月17号
4152
+ * @param home DSH home(测试可注入;缺省按环境解析)
4153
+ * @returns 清单项数组
4154
+ */
4155
+ async function listDebugLogs(home = dshHome()) {
4156
+ const names = await readdir(pluginLogRoot(home)).catch(() => []);
4157
+ const items = [];
4158
+ for (const name of names) {
4159
+ if (!isDebugLogName(name)) continue;
4160
+ const info = await stat(join(pluginLogRoot(home), name)).catch(() => null);
4161
+ if (!info?.isFile()) continue;
4162
+ items.push({
4163
+ name,
4164
+ bytes: info.size,
4165
+ mtimeMs: info.mtimeMs
4166
+ });
4087
4167
  }
4168
+ items.sort((a, b) => b.mtimeMs - a.mtimeMs);
4169
+ return items;
4088
4170
  }
4089
4171
  /**
4090
- * 解析主 sidecar:v2 直接返回;v1 迁移为 v2 结构(按会话 cwd 分桶)。
4091
- * @author ddj 2026年08月20号
4092
- * @param text sidecar 文本
4093
- * @returns v2 数据或 null
4172
+ * 尾部读取一份诊断日志:保留文件尾 maxBytes 字节(缺省 256KB),truncated 标记截头。
4173
+ * 单文件本体受 DEBUG_LOG_CAP=512KB 约束,整读为字符串的成本可接受。
4174
+ * @author ddj 2026年09月17号
4175
+ * @param name 日志文件名(isDebugLogName 白名单校验,非法直接 null)
4176
+ * @param maxBytes 尾部保留字节数(缺省 256KB)
4177
+ * @param home DSH home(测试可注入)
4178
+ * @returns 读取结果;文件不存在或名字非法返回 null
4094
4179
  */
4095
- function parseSidecar(text) {
4096
- if (!text) return null;
4180
+ async function readDebugLog(name, maxBytes = 262144, home = dshHome()) {
4181
+ if (!isDebugLogName(name)) return null;
4182
+ const target = join(pluginLogRoot(home), name);
4183
+ const info = await stat(target).catch(() => null);
4184
+ if (!info?.isFile()) return null;
4185
+ const text = await readFile(target, "utf8").catch(() => null);
4186
+ if (text === null) return null;
4187
+ const cut = Math.max(0, text.length - maxBytes);
4188
+ return {
4189
+ name,
4190
+ path: target,
4191
+ content: cut > 0 ? text.slice(cut) : text,
4192
+ size: info.size,
4193
+ truncated: cut > 0
4194
+ };
4195
+ }
4196
+ /**
4197
+ * 清空一份诊断日志(truncate 到 0,保留文件本身)。
4198
+ * @author ddj 2026年09月17号
4199
+ * @param name 日志文件名(isDebugLogName 白名单校验)
4200
+ * @param home DSH home(测试可注入)
4201
+ * @returns 是否清空成功(文件不存在视为未成功,文案由调用方给)
4202
+ */
4203
+ async function clearDebugLog(name, home = dshHome()) {
4204
+ if (!isDebugLogName(name)) return false;
4205
+ return await truncate(join(pluginLogRoot(home), name)).then(() => true).catch(() => false);
4206
+ }
4207
+ //#endregion
4208
+ //#region src/archiveDb.ts
4209
+ /**
4210
+ * 工作区差异归档 V2:SQLite 事务追加与按需读取;旧 JSON 只读留存,不参与热路径。
4211
+ * 数据库位于 DSH home 的 data/,不会被缓存清理器删除。
4212
+ * 作者 ddj 2026-09-30
4213
+ */
4214
+ /**
4215
+ * 数据库行中 null 批次用独立字符串键,避免 SQLite UNIQUE 对 NULL 不去重。
4216
+ * @author ddj 2026年09月30号
4217
+ * @param batch 原始批次
4218
+ * @returns 可用于唯一约束的字符串键
4219
+ */
4220
+ const batchKey = (batch) => batch === null ? "null" : "n" + batch;
4221
+ /** 仅由本插件打开的工作区连接;插件卸载时 closeArchiveDbs 关闭。 */
4222
+ const connections = /* @__PURE__ */ new Map();
4223
+ /**
4224
+ * 打开且核验单工作区数据库;已有异常库绝不重置成空库。
4225
+ * @author ddj 2026年09月30号
4226
+ * @param cwd 工作区绝对路径
4227
+ * @returns 已缓存连接
4228
+ */
4229
+ function dbFor(cwd) {
4230
+ const existing = connections.get(cwd);
4231
+ if (existing) return existing;
4232
+ const file = archiveDbFile(cwd);
4233
+ mkdirSync(dirname(file), {
4234
+ recursive: true,
4235
+ mode: 448
4236
+ });
4237
+ const db = new DatabaseSync(file, { timeout: 2e3 });
4097
4238
  try {
4098
- const data = JSON.parse(text);
4099
- if (data && typeof data === "object" && data.version === 2 && data.workspaces && typeof data.workspaces === "object") return data;
4100
- if (data && data.version === 1 && data.sessions && typeof data.sessions === "object") {
4101
- const workspaces = {};
4102
- for (const bucket of Object.values(data.sessions)) {
4103
- const b = bucket;
4104
- if (typeof b.cwd === "string" && b.records) workspaces[b.cwd] = {
4105
- at: typeof b.at === "number" ? b.at : Date.now(),
4106
- records: b.records
4107
- };
4239
+ db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=2000");
4240
+ db.exec("CREATE TABLE IF NOT EXISTS metadata (name TEXT PRIMARY KEY, value TEXT NOT NULL)");
4241
+ const version = db.prepare("SELECT value FROM metadata WHERE name='schema'").get();
4242
+ const workspace = db.prepare("SELECT value FROM metadata WHERE name='cwd'").get();
4243
+ if (version && version.value !== "2" && version.value !== "3" || workspace && workspace.value !== cwd) throw new Error("归档数据库 schema/工作区不匹配");
4244
+ if (!!version !== !!workspace) throw new Error("归档数据库元数据不完整");
4245
+ if (!version) {
4246
+ if (db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name<>'metadata' LIMIT 1").get()) throw new Error("归档数据库缺失元数据且已有数据表");
4247
+ }
4248
+ db.exec(`CREATE TABLE IF NOT EXISTS batches (
4249
+ id INTEGER PRIMARY KEY, cwd TEXT NOT NULL, path TEXT NOT NULL, batch_key TEXT NOT NULL,
4250
+ batch INTEGER, at TEXT NOT NULL, last_at TEXT, reason TEXT,
4251
+ accepted INTEGER NOT NULL DEFAULT 0, rejected INTEGER NOT NULL DEFAULT 0,
4252
+ pending INTEGER NOT NULL DEFAULT 0, superseded INTEGER NOT NULL DEFAULT 0,
4253
+ n_records INTEGER NOT NULL DEFAULT 0, UNIQUE(cwd,path,batch_key)
4254
+ );
4255
+ CREATE TABLE IF NOT EXISTS records (
4256
+ batch_id INTEGER NOT NULL REFERENCES batches(id), call_id TEXT NOT NULL,
4257
+ payload TEXT NOT NULL, accepted INTEGER NOT NULL, rejected INTEGER NOT NULL,
4258
+ pending INTEGER NOT NULL, superseded INTEGER NOT NULL,
4259
+ PRIMARY KEY(batch_id,call_id)
4260
+ );
4261
+ CREATE INDEX IF NOT EXISTS archive_by_path ON batches(cwd,path,batch);
4262
+ CREATE INDEX IF NOT EXISTS archive_by_call ON records(call_id);
4263
+ CREATE TABLE IF NOT EXISTS revert_intents (
4264
+ call_id TEXT NOT NULL, scope TEXT NOT NULL, hunk_index INTEGER NOT NULL,
4265
+ state TEXT NOT NULL CHECK(state IN ('started','applied')),
4266
+ PRIMARY KEY(call_id,scope,hunk_index)
4267
+ )`);
4268
+ if (!version) {
4269
+ db.exec("BEGIN IMMEDIATE");
4270
+ try {
4271
+ db.prepare("INSERT INTO metadata(name,value) VALUES('schema','2')").run();
4272
+ db.prepare("INSERT INTO metadata(name,value) VALUES('cwd',?)").run(cwd);
4273
+ db.exec("COMMIT");
4274
+ } catch (error) {
4275
+ if (db.isTransaction) db.exec("ROLLBACK");
4276
+ throw error;
4108
4277
  }
4109
- return {
4110
- version: 2,
4111
- updatedAt: typeof data.updatedAt === "string" ? data.updatedAt : (/* @__PURE__ */ new Date()).toISOString(),
4112
- workspaces
4113
- };
4114
4278
  }
4115
- return null;
4279
+ if (version?.value === "3") {
4280
+ const active = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='active_records'").get();
4281
+ const source = db.prepare("SELECT value FROM metadata WHERE name='active_source'").get();
4282
+ if (!active || !source) throw new Error("活跃记录数据库元数据不完整");
4283
+ }
4284
+ connections.set(cwd, db);
4285
+ return db;
4116
4286
  } catch (error) {
4117
- return null;
4118
- }
4119
- }
4120
- /** 加载某工作区的记录桶(内存 Map;缺失 → 空)。 */
4121
- async function loadBucket(ctx, cwd) {
4122
- const data = parseSidecar(await readSidecarText(ctx, cwd));
4123
- const map = /* @__PURE__ */ new Map();
4124
- if (data && data.workspaces[cwd] && typeof data.workspaces[cwd].records === "object") for (const rec of Object.values(data.workspaces[cwd].records)) {
4125
- const n = normalizeRecord(rec);
4126
- if (n) map.set(n.callId, n);
4287
+ db.close();
4288
+ throw error;
4127
4289
  }
4128
- return map;
4129
4290
  }
4130
- /** 保存某工作区的记录桶(写前合并,不覆盖其他工作区)。 */
4131
- async function saveBucket(ctx, cwd, recsMap, session) {
4132
- const fs = ctx.get("fs");
4133
- if (!fs || !cwd) return;
4291
+ /**
4292
+ * 只在隔离的切换窗口导入旧 sidecar;不接受模糊空库或解析损坏的输入。
4293
+ * @author ddj 2026年09月30号
4294
+ * @param cwd 工作区
4295
+ * @param source 旧文件原文(null 仅表示明确不存在)
4296
+ * @param confirm 再读源文件原文,防止迁移途中旧 Host 改写
4297
+ * @returns 活跃记录 Map
4298
+ */
4299
+ async function migrateActive(cwd, source, confirm) {
4300
+ const db = dbFor(cwd);
4301
+ if (String(db.prepare("SELECT value FROM metadata WHERE name='schema'").get().value) === "3") return readActive(cwd);
4302
+ const digest = source === null ? "absent" : createHash("sha256").update(source).digest("hex");
4303
+ let recs = {};
4304
+ if (source !== null) {
4305
+ let parsed;
4306
+ try {
4307
+ parsed = JSON.parse(source);
4308
+ } catch {
4309
+ throw new Error("旧活跃 sidecar 损坏,停止迁移");
4310
+ }
4311
+ if (parsed?.version !== 2 || !parsed.workspaces || typeof parsed.workspaces !== "object" || Array.isArray(parsed.workspaces)) throw new Error("旧活跃 sidecar 格式无效");
4312
+ const workspace = parsed.workspaces[cwd];
4313
+ if (workspace && (!workspace.records || typeof workspace.records !== "object" || Array.isArray(workspace.records))) throw new Error("旧工作区记录格式无效");
4314
+ recs = workspace?.records ?? {};
4315
+ for (const [key, rec] of Object.entries(recs)) if (!rec || rec.callId !== key || typeof rec.path !== "string" || !Array.isArray(rec.hunks) || !rec.decisions || !Array.isArray(rec.decisions.perHunk) || !Number.isInteger(rec.batch) || typeof rec.at !== "string") throw new Error("旧活跃记录损坏:" + key);
4316
+ }
4317
+ if (await confirm() !== source) throw new Error("迁移期间旧活跃 sidecar 被修改");
4318
+ if (activeReady(cwd)) return readActive(cwd);
4319
+ db.exec("BEGIN IMMEDIATE");
4134
4320
  try {
4135
- const target = await fs.resolve(SIDECAR, { cwd });
4136
- const existing = parseSidecar(await readSidecarText(ctx, cwd)) ?? {
4137
- version: 2,
4138
- updatedAt: "",
4139
- workspaces: {}
4140
- };
4141
- existing.workspaces[cwd] = {
4142
- at: Date.now(),
4143
- records: Object.fromEntries(recsMap)
4144
- };
4145
- existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4146
- await fs.writeText(target, JSON.stringify(existing), void 0, void 0, policyOf(ctx, session));
4321
+ db.exec(`CREATE TABLE active_records(call_id TEXT PRIMARY KEY, payload TEXT NOT NULL,
4322
+ path TEXT NOT NULL, archived INTEGER NOT NULL, pending INTEGER NOT NULL, at TEXT NOT NULL);
4323
+ CREATE INDEX active_by_path ON active_records(path,archived)`);
4324
+ const add = db.prepare("INSERT INTO active_records(call_id,payload,path,archived,pending,at) VALUES(?,?,?,?,?,?)");
4325
+ for (const rec of Object.values(recs)) add.run(rec.callId, JSON.stringify(rec), rec.path, rec.archived ? 1 : 0, rec.decisions?.perHunk?.filter((d) => d === "pending").length ?? 0, rec.at);
4326
+ if (await confirm() !== source) throw new Error("迁移期间旧活跃 sidecar 被修改");
4327
+ db.prepare("INSERT INTO metadata(name,value) VALUES('active_source',?)").run(digest);
4328
+ db.prepare("UPDATE metadata SET value='3' WHERE name='schema'").run();
4329
+ db.exec("COMMIT");
4330
+ return new Map(Object.values(recs).map((rec) => [rec.callId, rec]));
4147
4331
  } catch (error) {
4148
- log.error("saveBucket failed: " + String(error));
4332
+ if (db.isTransaction) db.exec("ROLLBACK");
4333
+ throw error;
4149
4334
  }
4150
4335
  }
4151
4336
  /**
4152
- * 读取记录目标文件内容(按 path 缓存一次;缺失/过大/失败 → null/'' 占位)。
4153
- * @author ddj 2026年08月20号
4154
- * @param cache path → 内容(null=缺失/读取失败,''=过大跳过)
4337
+ * 判断已核验的工作区是否完成活跃表迁移,不写入工作区记录。
4338
+ * @author ddj 2026年09月30号
4339
+ * @param cwd 工作区
4340
+ * @returns 迁移完成返回 true
4155
4341
  */
4156
- async function readForCache(ctx, session, cache, path) {
4157
- const cached = cache.get(path);
4158
- if (cached) return cached;
4159
- const fs = ctx.get("fs");
4160
- if (!fs) {
4161
- const state = { kind: "unavailable" };
4342
+ function activeReady(cwd) {
4343
+ return dbFor(cwd).prepare("SELECT value FROM metadata WHERE name='schema'").get().value === "3";
4344
+ }
4345
+ /**
4346
+ * 对旧文件原文核对迁移标记,旧 Host 若继续写旧 sidecar 则拒绝启用新活跃库。
4347
+ * @author ddj 2026年09月30号
4348
+ * @param cwd 工作区
4349
+ * @param source 当前遗留文件原文
4350
+ */
4351
+ function checkActiveSource(cwd, source) {
4352
+ const row = dbFor(cwd).prepare("SELECT value FROM metadata WHERE name='active_source'").get();
4353
+ if (!row) throw new Error("活跃记录缺少迁移标记");
4354
+ const digest = source === null ? "absent" : createHash("sha256").update(source).digest("hex");
4355
+ if (row.value !== digest) throw new Error("旧活跃 sidecar 在迁移后发生变化,请停止操作并人工核对");
4356
+ }
4357
+ /**
4358
+ * 只读当前工作区活跃表;未迁移时禁止回退到空库。
4359
+ * @author ddj 2026年09月30号
4360
+ * @param cwd 工作区
4361
+ * @returns 完整有限记录桶
4362
+ */
4363
+ function readActive(cwd) {
4364
+ const db = dbFor(cwd);
4365
+ if (db.prepare("SELECT value FROM metadata WHERE name='schema'").get().value !== "3") throw new Error("工作区活跃记录未迁移");
4366
+ const recs = db.prepare("SELECT payload FROM active_records ORDER BY rowid").all().map((row) => JSON.parse(String(row.payload)));
4367
+ return new Map(recs.map((rec) => [rec.callId, rec]));
4368
+ }
4369
+ /**
4370
+ * 单次 SQLite 事务提交活跃行变化及可选归档,绝不重写整个记录桶。
4371
+ * @author ddj 2026年09月30号
4372
+ * @param cwd 工作区
4373
+ * @param changes 明确新增、更新和移除的记录
4374
+ * @param entries 可选本次归档批次
4375
+ * @returns 新增归档批次数
4376
+ */
4377
+ function commitActive(cwd, changes, entries = []) {
4378
+ const db = dbFor(cwd);
4379
+ if (db.prepare("SELECT value FROM metadata WHERE name='schema'").get().value !== "3") throw new Error("工作区活跃记录未迁移");
4380
+ const upsert = db.prepare(`INSERT INTO active_records(call_id,payload,path,archived,pending,at) VALUES(?,?,?,?,?,?)
4381
+ ON CONFLICT(call_id) DO UPDATE SET payload=excluded.payload,path=excluded.path,
4382
+ archived=excluded.archived,pending=excluded.pending,at=excluded.at`);
4383
+ const remove = db.prepare("DELETE FROM active_records WHERE call_id=?");
4384
+ db.exec("BEGIN IMMEDIATE");
4385
+ try {
4386
+ const added = entries.length ? insertArchive(db, cwd, entries) : 0;
4387
+ for (const rec of changes.upserts) {
4388
+ const pending = rec.decisions?.perHunk?.filter((decision) => decision === "pending").length ?? 0;
4389
+ upsert.run(rec.callId, JSON.stringify(rec), rec.path, rec.archived ? 1 : 0, pending, rec.at);
4390
+ }
4391
+ for (const callId of changes.removed) remove.run(callId);
4392
+ db.exec("COMMIT");
4393
+ return added;
4394
+ } catch (error) {
4395
+ if (db.isTransaction) db.exec("ROLLBACK");
4396
+ throw error;
4397
+ }
4398
+ }
4399
+ /**
4400
+ * 轻量活跃摘要;旧 JSON 保留为迁移前快照而不参与统计。
4401
+ * @author ddj 2026年09月30号
4402
+ * @param cwd 工作区
4403
+ * @returns 活跃条数和每文件待处理计数
4404
+ */
4405
+ function activeSummary(cwd) {
4406
+ const db = dbFor(cwd);
4407
+ if (db.prepare("SELECT value FROM metadata WHERE name='schema'").get().value !== "3") throw new Error("工作区活跃记录未迁移");
4408
+ return {
4409
+ active: Number(db.prepare("SELECT count(*) AS n FROM active_records WHERE archived=0").get().n),
4410
+ pendingByFile: db.prepare("SELECT path, sum(pending) AS pending FROM active_records WHERE archived=0 AND pending>0 GROUP BY path ORDER BY pending DESC").all().map((row) => ({
4411
+ path: String(row.path),
4412
+ pending: Number(row.pending)
4413
+ }))
4414
+ };
4415
+ }
4416
+ /**
4417
+ * 对一批同路径记录做事务内去重追加,返回成功插入的批次数。
4418
+ * @author ddj 2026年09月30号
4419
+ * @param cwd 工作区
4420
+ * @param entries 已按旧语义构造的归档批次
4421
+ * @returns 新增批次数
4422
+ */
4423
+ function appendArchive(cwd, entries) {
4424
+ if (!entries.length) return 0;
4425
+ const db = dbFor(cwd);
4426
+ db.exec("BEGIN IMMEDIATE");
4427
+ try {
4428
+ const added = insertArchive(db, cwd, entries);
4429
+ db.exec("COMMIT");
4430
+ return added;
4431
+ } catch (error) {
4432
+ if (db.isTransaction) db.exec("ROLLBACK");
4433
+ throw error;
4434
+ }
4435
+ }
4436
+ /**
4437
+ * 在调用方事务内追加归档批次;归档与活跃行可共用一次提交。
4438
+ * @author ddj 2026年09月30号
4439
+ * @param db 当前工作区连接
4440
+ * @param cwd 工作区
4441
+ * @param entries 待归档条目
4442
+ * @returns 新增批次数
4443
+ */
4444
+ function insertArchive(db, cwd, entries) {
4445
+ const get = db.prepare("SELECT id FROM batches WHERE cwd=? AND path=? AND batch_key=?");
4446
+ const add = db.prepare("INSERT INTO batches(cwd,path,batch_key,batch,at,last_at,reason) VALUES(?,?,?,?,?,?,?)");
4447
+ const update = db.prepare("UPDATE batches SET last_at=?, reason=? WHERE id=?");
4448
+ const insert = db.prepare("INSERT OR IGNORE INTO records(batch_id,call_id,payload,accepted,rejected,pending,superseded) VALUES(?,?,?,?,?,?,?)");
4449
+ const tally = db.prepare(`UPDATE batches SET n_records=n_records+1, accepted=accepted+?, rejected=rejected+?,
4450
+ pending=pending+?, superseded=superseded+? WHERE id=?`);
4451
+ let added = 0;
4452
+ for (const entry of entries) {
4453
+ if (entry.cwd !== cwd) throw new Error("归档批次工作区不匹配");
4454
+ const key = batchKey(entry.batch);
4455
+ const found = get.get(cwd, entry.path, key);
4456
+ const id = found ? Number(found.id) : Number(add.run(cwd, entry.path, key, entry.batch, entry.at, entry.lastAt ?? null, entry.reason).lastInsertRowid);
4457
+ if (found) update.run(entry.at, entry.reason, id);
4458
+ else added++;
4459
+ for (const record of entry.records) {
4460
+ const sum = record.summary;
4461
+ if (insert.run(id, record.callId, JSON.stringify(record), sum.accepted, sum.rejected, sum.pending, sum.superseded ? 1 : 0).changes) tally.run(sum.accepted, sum.rejected, sum.pending, sum.superseded ? 1 : 0, id);
4462
+ }
4463
+ }
4464
+ const clear = db.prepare("DELETE FROM revert_intents WHERE call_id=?");
4465
+ for (const entry of entries) for (const record of entry.records) clear.run(record.callId);
4466
+ return added;
4467
+ }
4468
+ /**
4469
+ * 返回某工作区所有归档批次的轻量摘要,不取记录 JSON。
4470
+ * @author ddj 2026年09月30号
4471
+ * @param cwd 工作区
4472
+ * @returns 归档列表
4473
+ */
4474
+ function listArchives(cwd) {
4475
+ return dbFor(cwd).prepare(`SELECT path,batch,at,last_at,reason,n_records,accepted,rejected,pending,superseded
4476
+ FROM batches WHERE cwd=? ORDER BY COALESCE(batch,-1) DESC, at DESC`).all(cwd).map((row) => ({
4477
+ path: String(row.path),
4478
+ batch: row.batch === null ? null : Number(row.batch),
4479
+ at: String(row.at),
4480
+ lastAt: String(row.last_at ?? row.at),
4481
+ reason: row.reason === null ? null : String(row.reason),
4482
+ nRecords: Number(row.n_records),
4483
+ summary: {
4484
+ accepted: Number(row.accepted),
4485
+ rejected: Number(row.rejected),
4486
+ pending: Number(row.pending),
4487
+ superseded: Number(row.superseded)
4488
+ }
4489
+ }));
4490
+ }
4491
+ /**
4492
+ * 固定快照上界的 keyset 分页,新增批次不挤动后续页面。
4493
+ * @author ddj 2026年09月30号
4494
+ * @param cwd 工作区
4495
+ * @param cursor 上页返回的不透明游标
4496
+ * @param limit 每页条数,缺省 50,最多 100
4497
+ * @returns 摘要页和下一页游标
4498
+ */
4499
+ function pageArchives(cwd, cursor, limit = 50) {
4500
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error("归档分页大小不合法");
4501
+ const db = dbFor(cwd);
4502
+ const newest = Number(db.prepare("SELECT COALESCE(MAX(id),0) AS id FROM batches WHERE cwd=?").get(cwd).id);
4503
+ let snapshot = newest;
4504
+ let last = null;
4505
+ if (cursor) try {
4506
+ const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
4507
+ if (value.cwd !== cwd || !Number.isSafeInteger(value.snapshot) || value.snapshot < 0 || value.snapshot > newest || !Array.isArray(value.last) || value.last.length !== 3 || !Number.isSafeInteger(value.last[0]) || typeof value.last[1] !== "string" || !Number.isSafeInteger(value.last[2]) || value.last[2] > value.snapshot) throw new Error("invalid");
4508
+ snapshot = value.snapshot;
4509
+ last = value.last;
4510
+ } catch {
4511
+ throw new Error("归档分页游标无效");
4512
+ }
4513
+ const args = [cwd, snapshot];
4514
+ let where = "WHERE cwd=? AND id<=?";
4515
+ if (last) {
4516
+ where += " AND (COALESCE(batch,-1),at,id) < (?,?,?)";
4517
+ args.push(...last);
4518
+ }
4519
+ const rows = db.prepare(`SELECT id,path,batch,at,last_at,reason,n_records,accepted,rejected,pending,superseded
4520
+ FROM batches ${where} ORDER BY COALESCE(batch,-1) DESC, at DESC, id DESC LIMIT ?`).all(...args, limit + 1);
4521
+ const entries = rows.slice(0, limit).map((row) => ({
4522
+ path: String(row.path),
4523
+ batch: row.batch === null ? null : Number(row.batch),
4524
+ at: String(row.at),
4525
+ lastAt: String(row.last_at ?? row.at),
4526
+ reason: row.reason === null ? null : String(row.reason),
4527
+ nRecords: Number(row.n_records),
4528
+ summary: {
4529
+ accepted: Number(row.accepted),
4530
+ rejected: Number(row.rejected),
4531
+ pending: Number(row.pending),
4532
+ superseded: Number(row.superseded)
4533
+ }
4534
+ }));
4535
+ const tail = rows[limit - 1];
4536
+ return {
4537
+ entries,
4538
+ nextCursor: rows.length > limit && tail ? Buffer.from(JSON.stringify({
4539
+ cwd,
4540
+ snapshot,
4541
+ last: [
4542
+ tail.batch === null ? -1 : Number(tail.batch),
4543
+ tail.at,
4544
+ tail.id
4545
+ ]
4546
+ })).toString("base64url") : null
4547
+ };
4548
+ }
4549
+ /**
4550
+ * 仅查询需要展开的路径/批次(无 path 时维持 RPC 返回全库语义)。
4551
+ * @author ddj 2026年09月30号
4552
+ * @param cwd 工作区
4553
+ * @param path 可选目标路径
4554
+ * @param batch 可选批次
4555
+ * @returns 原有 ArchiveBatch 格式
4556
+ */
4557
+ function readArchives(cwd, path, batch) {
4558
+ const db = dbFor(cwd);
4559
+ let where = "WHERE cwd=?";
4560
+ const args = [cwd];
4561
+ if (path) {
4562
+ where += " AND path=?";
4563
+ args.push(path);
4564
+ }
4565
+ if (batch !== void 0) {
4566
+ where += " AND batch_key=?";
4567
+ args.push(batchKey(batch));
4568
+ }
4569
+ const rows = db.prepare(`SELECT id,cwd,path,batch,at,last_at,reason FROM batches ${where} ORDER BY id`).all(...args);
4570
+ const records = db.prepare("SELECT payload FROM records WHERE batch_id=? ORDER BY rowid");
4571
+ return rows.map((row) => ({
4572
+ cwd: String(row.cwd),
4573
+ path: String(row.path),
4574
+ batch: row.batch === null ? null : Number(row.batch),
4575
+ at: String(row.at),
4576
+ ...row.last_at === null ? {} : { lastAt: String(row.last_at) },
4577
+ reason: String(row.reason ?? ""),
4578
+ records: records.all(row.id).map((record) => JSON.parse(String(record.payload)))
4579
+ }));
4580
+ }
4581
+ /**
4582
+ * 查询已提交的归档记录,用于数据库成功但活跃 sidecar 写入失败后的幂等补偿。
4583
+ * @author ddj 2026年09月30号
4584
+ * @param cwd 工作区
4585
+ * @param callId 调用标识
4586
+ * @param path 目标文件路径
4587
+ * @param batch 对应文件批次
4588
+ * @returns 已归档快照或 null
4589
+ */
4590
+ function committedRecord(cwd, callId, path, batch) {
4591
+ const row = dbFor(cwd).prepare(`SELECT r.payload FROM records r JOIN batches b ON b.id=r.batch_id
4592
+ WHERE b.cwd=? AND b.path=? AND b.batch_key=? AND r.call_id=? LIMIT 1`).get(cwd, path, batchKey(batch), callId);
4593
+ return row ? JSON.parse(String(row.payload)) : null;
4594
+ }
4595
+ /**
4596
+ * 查询一次 Undo 的持久化阶段;started 表示写入结果无法仅凭 sidecar 判断。
4597
+ * @author ddj 2026年09月30号
4598
+ * @param cwd 工作区
4599
+ * @param callId 记录标识
4600
+ * @param scope 决策范围
4601
+ * @param hunkIndex 块索引(整调用为 -1)
4602
+ * @returns 阶段或 null
4603
+ */
4604
+ function revertState(cwd, callId, scope, hunkIndex) {
4605
+ const row = dbFor(cwd).prepare("SELECT state FROM revert_intents WHERE call_id=? AND scope=? AND hunk_index=?").get(callId, scope, hunkIndex);
4606
+ return row ? String(row.state) : null;
4607
+ }
4608
+ /**
4609
+ * 持久化 Undo 阶段(started 在写文件前,applied 在写文件后)。
4610
+ * @author ddj 2026年09月30号
4611
+ * @param cwd 工作区
4612
+ * @param callId 记录标识
4613
+ * @param scope 决策范围
4614
+ * @param hunkIndex 块索引
4615
+ * @param state 阶段
4616
+ */
4617
+ function setRevertState(cwd, callId, scope, hunkIndex, state) {
4618
+ dbFor(cwd).prepare(`INSERT INTO revert_intents(call_id,scope,hunk_index,state) VALUES(?,?,?,?)
4619
+ ON CONFLICT(call_id,scope,hunk_index) DO UPDATE SET state=excluded.state`).run(callId, scope, hunkIndex, state);
4620
+ }
4621
+ /**
4622
+ * 一次事务登记同记录的所有批量 Undo 意图,登记不完整时不写任何文件。
4623
+ * @author ddj 2026年09月30号
4624
+ * @param cwd 工作区
4625
+ * @param callId 记录标识
4626
+ * @param indices 块索引
4627
+ */
4628
+ function startReverts(cwd, callId, indices) {
4629
+ const db = dbFor(cwd);
4630
+ const insert = db.prepare(`INSERT INTO revert_intents(call_id,scope,hunk_index,state) VALUES(?,'hunk',?,'started')`);
4631
+ db.exec("BEGIN IMMEDIATE");
4632
+ try {
4633
+ for (const index of indices) insert.run(callId, index);
4634
+ db.exec("COMMIT");
4635
+ } catch (error) {
4636
+ if (db.isTransaction) db.exec("ROLLBACK");
4637
+ throw error;
4638
+ }
4639
+ }
4640
+ /**
4641
+ * 撤销尚未执行成功的 Undo 意图,保留成功写入后的阶段供补偿。
4642
+ * @author ddj 2026年09月30号
4643
+ * @param cwd 工作区
4644
+ * @param callId 记录标识
4645
+ * @param scope 决策范围
4646
+ * @param hunkIndex 块索引
4647
+ */
4648
+ function clearRevertState(cwd, callId, scope, hunkIndex) {
4649
+ dbFor(cwd).prepare("DELETE FROM revert_intents WHERE call_id=? AND scope=? AND hunk_index=?").run(callId, scope, hunkIndex);
4650
+ }
4651
+ /**
4652
+ * 已执行但 sidecar 未更新的 Undo,从磁盘恢复决定而不重写文件。
4653
+ * @author ddj 2026年09月30号
4654
+ * @param cwd 工作区
4655
+ * @param record 活跃记录
4656
+ * @returns 是否应用了持久化决定
4657
+ */
4658
+ function restoreReverts(cwd, record) {
4659
+ const rows = dbFor(cwd).prepare("SELECT scope,hunk_index,state FROM revert_intents WHERE call_id=?").all(record.callId);
4660
+ let changed = false;
4661
+ for (const row of rows) {
4662
+ if (row.state !== "applied") continue;
4663
+ if (row.scope === "call") {
4664
+ record.decisions.call = "rejected";
4665
+ record.decisions.perHunk = record.decisions.perHunk.map(() => "rejected");
4666
+ } else if (row.scope === "hunk" && Number(row.hunk_index) >= 0 && Number(row.hunk_index) < record.hunks.length) record.decisions.perHunk[Number(row.hunk_index)] = "rejected";
4667
+ else continue;
4668
+ changed = true;
4669
+ }
4670
+ return changed;
4671
+ }
4672
+ /**
4673
+ * 关闭所有工作区连接,供 Host 卸载以及测试释放 SQLite 句柄。
4674
+ * @author ddj 2026年09月30号
4675
+ */
4676
+ function closeArchiveDbs() {
4677
+ for (const db of connections.values()) db.close();
4678
+ connections.clear();
4679
+ }
4680
+ //#endregion
4681
+ //#region src/store.ts
4682
+ const READ_CAP$1 = 8388608;
4683
+ /** 二进制预览/写回上限(PDF 等整文件 base64 传输;文本侧仍用 READ_CAP)。 */
4684
+ const BINARY_READ_CAP = 33554432;
4685
+ /** 旧文件首次全量指纹核验后,每次操作优先检查 DSH fs stat 身份。 */
4686
+ const sourceStats = /* @__PURE__ */ new Map();
4687
+ /** 会话沙箱策略(policyOf):可能不存在,调用方需容忍 undefined。 */
4688
+ function policyOf(ctx, session) {
4689
+ const svc = ctx.get("sandboxPolicy");
4690
+ if (!svc) return void 0;
4691
+ return session ? svc.resolve({ session }) : svc.resolve();
4692
+ }
4693
+ /** 按会话 cwd 解析目标路径。 */
4694
+ async function resolveTarget(ctx, session, path) {
4695
+ const fs = ctx.get("fs");
4696
+ const cwd = session?.header?.cwd;
4697
+ return fs.resolve(path, cwd ? { cwd } : {});
4698
+ }
4699
+ /**
4700
+ * 严格读取旧 sidecar:仅确认确实不存在时才允许从空记录迁移。
4701
+ * @author ddj 2026年09月30号
4702
+ * @param ctx DSH 上下文
4703
+ * @param cwd 工作区
4704
+ * @returns 旧文件内容或确认缺失
4705
+ */
4706
+ async function migrationText(ctx, cwd) {
4707
+ const fs = ctx.get("fs");
4708
+ if (!fs) throw new Error("无法迁移差异记录:缺少 fs");
4709
+ const target = await fs.resolve(SIDECAR, { cwd });
4710
+ const info = await fs.stat(target);
4711
+ if (!info) return null;
4712
+ if (info.type !== "file") throw new Error("旧活跃 sidecar 非文件");
4713
+ return fs.readText(target);
4714
+ }
4715
+ /**
4716
+ * 读取旧 sidecar 文件的版本身份;缺少可靠版本令牌时返回 null 走全文核验。
4717
+ * @author ddj 2026年09月30号
4718
+ * @param ctx DSH 上下文
4719
+ * @param cwd 工作区
4720
+ * @returns 文件身份或 null
4721
+ */
4722
+ async function sourceStat(ctx, cwd) {
4723
+ const fs = ctx.get("fs");
4724
+ if (!fs) throw new Error("无法核验旧 sidecar:缺少 fs");
4725
+ const info = await fs.stat(await fs.resolve(SIDECAR, { cwd }));
4726
+ if (!info) return "absent";
4727
+ const version = info.version ?? info.rev;
4728
+ return typeof version === "string" && version.length ? version : null;
4729
+ }
4730
+ /**
4731
+ * 首次读取旧文件全文确认数据库来源,后续只在 stat 版本变动时重新核验。
4732
+ * @author ddj 2026年09月30号
4733
+ * @param ctx DSH 上下文
4734
+ * @param cwd 工作区
4735
+ */
4736
+ async function checkSource(ctx, cwd) {
4737
+ const now = await sourceStat(ctx, cwd);
4738
+ if (now !== null && sourceStats.get(cwd) === now) return;
4739
+ checkActiveSource(cwd, await migrationText(ctx, cwd));
4740
+ if (now !== null) sourceStats.set(cwd, now);
4741
+ }
4742
+ /** 加载某工作区的记录桶(内存 Map;首次在隔离切换窗口严格迁移)。 */
4743
+ async function loadBucket(ctx, cwd) {
4744
+ const map = await migrateActive(cwd, await migrationText(ctx, cwd), () => migrationText(ctx, cwd));
4745
+ await checkSource(ctx, cwd);
4746
+ for (const rec of map.values()) {
4747
+ if (rec.archived) continue;
4748
+ const previous = JSON.stringify(rec);
4749
+ restoreReverts(cwd, rec);
4750
+ const committed = committedRecord(cwd, rec.callId, rec.path, rec.batch ?? null);
4751
+ if (committed) {
4752
+ rec.decisions = committed.decisions;
4753
+ rec.superseded = committed.superseded;
4754
+ rec.archived = true;
4755
+ }
4756
+ if (JSON.stringify(rec) !== previous) commitActive(cwd, {
4757
+ upserts: [rec],
4758
+ removed: []
4759
+ });
4760
+ }
4761
+ return map;
4762
+ }
4763
+ /**
4764
+ * 仅把明确变动的记录行写入 SQLite;旧 sidecar 不再写入。
4765
+ * @author ddj 2026年09月30号
4766
+ * @param ctx DSH 上下文
4767
+ * @param cwd 工作区
4768
+ * @param recsMap 供当前调用点核对的记录桶
4769
+ * @param session 会话(保留旧 API)
4770
+ * @param changes 明确的更新和删除集合
4771
+ */
4772
+ async function saveBucket(ctx, cwd, recsMap, session, changes) {
4773
+ if (!changes) throw new Error("增量保存缺少变更集合");
4774
+ for (const rec of changes.upserts) if (recsMap.get(rec.callId) !== rec) throw new Error("增量记录不在工作区桶内");
4775
+ try {
4776
+ await checkSource(ctx, cwd);
4777
+ commitActive(cwd, changes);
4778
+ } catch (error) {
4779
+ log.error("saveBucket failed: " + String(error));
4780
+ throw error;
4781
+ }
4782
+ }
4783
+ /**
4784
+ * 读取记录目标文件内容(按 path 缓存一次;缺失/过大/失败 → null/'' 占位)。
4785
+ * @author ddj 2026年08月20号
4786
+ * @param cache path → 内容(null=缺失/读取失败,''=过大跳过)
4787
+ */
4788
+ async function readForCache(ctx, session, cache, path) {
4789
+ const cached = cache.get(path);
4790
+ if (cached) return cached;
4791
+ const fs = ctx.get("fs");
4792
+ if (!fs) {
4793
+ const state = { kind: "unavailable" };
4162
4794
  cache.set(path, state);
4163
4795
  return state;
4164
4796
  }
@@ -4237,74 +4869,96 @@ async function autoArchiveStale(ctx, session, cwd, bucket) {
4237
4869
  if (await recordIsStale(ctx, session, cache, rec)) stale.push(rec);
4238
4870
  }
4239
4871
  if (!stale.length) return 0;
4872
+ const original = stale.map((rec) => ({
4873
+ rec,
4874
+ at: rec.at,
4875
+ superseded: rec.superseded
4876
+ }));
4240
4877
  for (const r of stale) {
4241
4878
  r.superseded = true;
4242
4879
  r.at = (/* @__PURE__ */ new Date()).toISOString();
4243
4880
  }
4244
- await archiveRecords(ctx, session, cwd, bucket, stale, "差异无法定位(已被后续修改覆盖),自动归档");
4245
- return stale.length;
4246
- }
4247
- /** 读取归档 sidecar 文本(缺失/失败 → null)。 */
4248
- async function readArchiveText(ctx, cwd) {
4249
- const fs = ctx.get("fs");
4250
- if (!fs || !cwd) return null;
4251
4881
  try {
4252
- return await fs.readText(await fs.resolve(SIDECAR_ARCHIVE, { cwd }));
4882
+ await archiveRecords(ctx, session, cwd, bucket, stale, "差异无法定位(已被后续修改覆盖),自动归档");
4253
4883
  } catch (error) {
4254
- return null;
4884
+ for (const item of original) {
4885
+ if (item.rec.archived) continue;
4886
+ item.rec.superseded = item.superseded;
4887
+ item.rec.at = item.at;
4888
+ }
4889
+ throw error;
4255
4890
  }
4891
+ return stale.length;
4256
4892
  }
4257
- /** 解析归档 sidecar(损坏 → 空数组)。 */
4258
- function parseArchive(text) {
4259
- if (!text) return [];
4260
- try {
4261
- const data = JSON.parse(text);
4262
- if (data && typeof data === "object" && Array.isArray(data.batches)) return data.batches;
4263
- } catch (error) {}
4264
- return [];
4265
- }
4266
- /** 追加归档条目(按 cwd+path+batch 合并,同 callId 去重)。 */
4267
- async function appendArchiveEntries(ctx, cwd, entries, session) {
4268
- const fs = ctx.get("fs");
4269
- if (!fs || !cwd) return;
4893
+ /**
4894
+ * 追加归档条目(按 cwd+path+batch 合并,同 callId 去重)。
4895
+ * @author ddj 2026年09月30号
4896
+ * @param ctx DSH 上下文
4897
+ * @param cwd 工作区
4898
+ * @param entries 待归档批次
4899
+ * @param session 会话
4900
+ * @param traceId 可选诊断追踪标识;有值时输出分段耗时
4901
+ */
4902
+ async function appendArchiveEntries(ctx, cwd, entries, session, traceId) {
4903
+ if (!cwd || !entries.length) return;
4904
+ const started = traceId ? performance.now() : 0;
4270
4905
  try {
4271
- const target = await fs.resolve(SIDECAR_ARCHIVE, { cwd });
4272
- const existing = parseArchive(await readArchiveText(ctx, cwd));
4273
- for (const e of entries) {
4274
- const idx = existing.findIndex((x) => x.cwd === e.cwd && x.path === e.path && x.batch === e.batch);
4275
- if (idx >= 0) {
4276
- const exist = existing[idx];
4277
- const seen = new Set((exist.records || []).map((r) => r.callId));
4278
- for (const rec of e.records) if (!seen.has(String(rec.callId))) {
4279
- exist.records.push(rec);
4280
- seen.add(String(rec.callId));
4281
- }
4282
- exist.lastAt = e.at;
4283
- exist.reason = e.reason;
4284
- } else existing.push(e);
4285
- }
4286
- const data = {
4287
- version: 1,
4288
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4289
- batches: existing
4290
- };
4291
- await fs.writeText(target, JSON.stringify(data), void 0, void 0, policyOf(ctx, session));
4906
+ await checkSource(ctx, cwd);
4907
+ const added = appendArchive(cwd, entries);
4908
+ try {
4909
+ if (traceId) debugRecord(ctx, cwd, `[DEBUG diffArchive] trace=${traceId} stage=done mode=sqlite entries=${entries.length} insertedBatches=${added} dbMs=${(performance.now() - started).toFixed(1)} totalMs=${(performance.now() - started).toFixed(1)}`);
4910
+ } catch {}
4292
4911
  } catch (error) {
4912
+ try {
4913
+ if (traceId) debugRecord(ctx, cwd, `[DEBUG diffArchive] trace=${traceId} stage=error mode=sqlite totalMs=${(performance.now() - started).toFixed(1)}`);
4914
+ } catch {}
4293
4915
  log.error("appendArchiveEntries failed: " + String(error));
4916
+ throw error;
4294
4917
  }
4295
4918
  }
4296
4919
  /**
4297
4920
  * 归档记录:标记 archived、按批次写入归档、落盘工作区桶。
4298
- * @param opts.deferSave 为 true 时只写归档、不落盘桶(由批量调用方统一 saveBucket 一次)
4921
+ * @author ddj 2026年09月30号
4922
+ * @param ctx DSH 上下文
4923
+ * @param session 会话
4924
+ * @param cwd 工作区
4925
+ * @param bucket 当前工作区记录桶
4926
+ * @param recs 待归档记录
4927
+ * @param reason 归档原因
4928
+ * @param opts deferSave 为 true 时只写归档、不落盘桶;traceId 用于可选分段计时
4299
4929
  */
4300
4930
  async function archiveRecords(ctx, session, cwd, bucket, recs, reason, opts) {
4301
4931
  if (!recs.length) return;
4302
4932
  const fresh = recs.filter((r) => !r.archived);
4303
- for (const r of recs) r.archived = true;
4304
- const entries = [];
4305
- for (const list of groupByBatch(recs).values()) entries.push(archiveEntryFor(list, cwd, reason));
4306
- await appendArchiveEntries(ctx, cwd, entries, session);
4307
- if (fresh.length && !opts?.deferSave) await saveBucket(ctx, cwd, bucket, session);
4933
+ const byFile = /* @__PURE__ */ new Map();
4934
+ for (const rec of recs) {
4935
+ const key = JSON.stringify([rec.path, Number.isInteger(rec.batch) ? rec.batch : null]);
4936
+ const list = byFile.get(key) ?? [];
4937
+ list.push(rec);
4938
+ byFile.set(key, list);
4939
+ }
4940
+ const entries = [...byFile.values()].map((list) => archiveEntryFor(list, cwd, reason));
4941
+ const changed = new Map((opts?.changed ?? []).map((rec) => [rec.callId, rec]));
4942
+ for (const rec of recs) changed.set(rec.callId, rec);
4943
+ const start = opts?.traceId ? performance.now() : 0;
4944
+ await checkSource(ctx, cwd);
4945
+ const original = recs.map((rec) => rec.archived);
4946
+ for (const rec of recs) rec.archived = true;
4947
+ try {
4948
+ const added = commitActive(cwd, {
4949
+ upserts: [...changed.values()],
4950
+ removed: []
4951
+ }, entries);
4952
+ try {
4953
+ if (opts?.traceId) debugRecord(ctx, cwd, `[DEBUG diffArchive] trace=${opts.traceId} stage=done mode=sqlite entries=${entries.length} insertedBatches=${added} dbMs=${(performance.now() - start).toFixed(1)} totalMs=${(performance.now() - start).toFixed(1)}`);
4954
+ } catch {}
4955
+ } catch (error) {
4956
+ recs.forEach((rec, idx) => {
4957
+ rec.archived = original[idx];
4958
+ });
4959
+ throw error;
4960
+ }
4961
+ if (fresh.length && !opts?.deferSave) return;
4308
4962
  }
4309
4963
  //#endregion
4310
4964
  //#region src/httpGuard.ts
@@ -5480,10 +6134,21 @@ async function persistRecord(ctx, registry, session, cwd, record) {
5480
6134
  bucket = await loadBucket(ctx, cwd);
5481
6135
  registry.set(cwd, bucket);
5482
6136
  }
6137
+ const prior = new Map(bucket);
5483
6138
  record.batch = fileMaxBatch(bucket, record.path) + 1;
5484
6139
  bucket.set(record.callId, record);
5485
6140
  prune(bucket);
5486
- await saveBucket(ctx, cwd, bucket, session);
6141
+ const removed = [...prior.keys()].filter((callId) => !bucket.has(callId));
6142
+ try {
6143
+ await saveBucket(ctx, cwd, bucket, session, {
6144
+ upserts: [record],
6145
+ removed
6146
+ });
6147
+ } catch (error) {
6148
+ bucket.clear();
6149
+ for (const [callId, rec] of prior) bucket.set(callId, rec);
6150
+ throw error;
6151
+ }
5487
6152
  }
5488
6153
  /**
5489
6154
  * 捕获一次工具执行结果(tools/result):edit/write/remote_ssh_write 成功时
@@ -6761,7 +7426,7 @@ async function revertCall(ctx, session, record) {
6761
7426
  }
6762
7427
  }
6763
7428
  /** 单 hunk 回滚:用 callHunk(edit 单 hunk 精确串)经 fs.editText 反替换。 */
6764
- async function revertHunk(ctx, session, record, idx) {
7429
+ async function revertHunk(ctx, session, record, idx, traceId) {
6765
7430
  const fs = ctx.get("fs");
6766
7431
  if (!fs) return {
6767
7432
  ok: false,
@@ -6772,8 +7437,13 @@ async function revertHunk(ctx, session, record, idx) {
6772
7437
  ok: false,
6773
7438
  error: "找不到该差异块"
6774
7439
  };
7440
+ const started = traceId ? performance.now() : 0;
7441
+ let readMs = 0;
7442
+ let locateMs = 0;
7443
+ let stage = "resolve";
6775
7444
  try {
6776
7445
  const target = await resolveTarget(ctx, session, record.path);
7446
+ stage = "stat";
6777
7447
  const info = await fs.stat(target);
6778
7448
  if (!info || info.type !== "file") return {
6779
7449
  ok: false,
@@ -6783,14 +7453,20 @@ async function revertHunk(ctx, session, record, idx) {
6783
7453
  ok: false,
6784
7454
  error: "回滚失败:文件过大,无法安全定位"
6785
7455
  };
7456
+ stage = "read";
6786
7457
  const content = await fs.readText(target);
7458
+ if (traceId) readMs = performance.now() - started;
7459
+ const locateStart = traceId ? performance.now() : 0;
7460
+ stage = "locate";
6787
7461
  const location = locateHunks(content, [precise])[0];
7462
+ if (traceId) locateMs = performance.now() - locateStart;
6788
7463
  if (!location?.matched) return {
6789
7464
  ok: false,
6790
7465
  stale: true,
6791
7466
  error: "回滚失败:该区域在文件中已不存在(可能已被后续修改覆盖)"
6792
7467
  };
6793
7468
  const result = applyLocations(content, [location], true);
7469
+ stage = "write";
6794
7470
  await fs.writeText(target, result.content, void 0, void 0, policyOf(ctx, session));
6795
7471
  return { ok: true };
6796
7472
  } catch (error) {
@@ -6798,6 +7474,88 @@ async function revertHunk(ctx, session, record, idx) {
6798
7474
  ok: false,
6799
7475
  error: "回滚失败:该区域可能已被后续修改影响(" + String(error) + ")"
6800
7476
  };
7477
+ } finally {
7478
+ if (traceId) try {
7479
+ debugRecord(ctx, session.header?.cwd ?? "", `[DEBUG diffRevert] trace=${traceId} mode=single stage=${stage} readMs=${readMs.toFixed(1)} locateMs=${locateMs.toFixed(1)} totalMs=${(performance.now() - started).toFixed(1)}`);
7480
+ } catch {}
7481
+ }
7482
+ }
7483
+ /**
7484
+ * 同一记录的连续 hunk 在内存里逐项回滚,只对文件读写各一次。
7485
+ * 不替代单条回滚;调用方只传确定同路径且非远端的连续项。
7486
+ * @author ddj 2026年09月29号
7487
+ * @param ctx DSH 上下文
7488
+ * @param session 会话
7489
+ * @param record 差异记录
7490
+ * @param indices 按决策顺序排列的 hunk 索引
7491
+ * @param traceId 可选的诊断追踪号,仅开启诊断时传入
7492
+ * @returns 每个 hunk 对应的独立执行结果
7493
+ */
7494
+ async function revertHunks(ctx, session, record, indices, traceId) {
7495
+ const fs = ctx.get("fs");
7496
+ if (!fs) return indices.map(() => ({
7497
+ ok: false,
7498
+ error: "回滚不可用:缺少 fs"
7499
+ }));
7500
+ const failAll = (error) => indices.map((idx) => preciseHunk(record, idx) ? {
7501
+ ok: false,
7502
+ error
7503
+ } : {
7504
+ ok: false,
7505
+ error: "找不到该差异块"
7506
+ });
7507
+ try {
7508
+ const started = traceId ? performance.now() : 0;
7509
+ const target = await resolveTarget(ctx, session, record.path);
7510
+ const info = await fs.stat(target);
7511
+ if (!info || info.type !== "file") return failAll("回滚失败:文件不存在");
7512
+ if ((info.size ?? 0) > 8388608) return failAll("回滚失败:文件过大,无法安全定位");
7513
+ let content = await fs.readText(target);
7514
+ const readMs = traceId ? performance.now() - started : 0;
7515
+ const locateStart = traceId ? performance.now() : 0;
7516
+ let size = info.size ?? Buffer.byteLength(content);
7517
+ const results = [];
7518
+ for (const idx of indices) {
7519
+ const hunk = preciseHunk(record, idx);
7520
+ if (!hunk) {
7521
+ results.push({
7522
+ ok: false,
7523
+ error: "找不到该差异块"
7524
+ });
7525
+ continue;
7526
+ }
7527
+ if (size > 8388608) {
7528
+ results.push({
7529
+ ok: false,
7530
+ error: "回滚失败:文件过大,无法安全定位"
7531
+ });
7532
+ continue;
7533
+ }
7534
+ const location = locateHunks(content, [hunk])[0];
7535
+ if (!location?.matched) {
7536
+ results.push({
7537
+ ok: false,
7538
+ stale: true,
7539
+ error: "回滚失败:该区域在文件中已不存在(可能已被后续修改覆盖)"
7540
+ });
7541
+ continue;
7542
+ }
7543
+ size += Buffer.byteLength(hunk.oldText ?? "") - Buffer.byteLength(content.slice(location.start, location.end));
7544
+ content = applyLocations(content, [location], true).content;
7545
+ results.push({ ok: true });
7546
+ }
7547
+ const locateMs = traceId ? performance.now() - locateStart : 0;
7548
+ const writeStart = traceId ? performance.now() : 0;
7549
+ if (results.some((result) => result.ok)) await fs.writeText(target, content, void 0, void 0, policyOf(ctx, session));
7550
+ try {
7551
+ if (traceId) {
7552
+ const totalMs = performance.now() - started;
7553
+ debugRecord(ctx, session.header?.cwd ?? "", `[DEBUG diffRevert] trace=${traceId} mode=batch hunks=${indices.length} bytes=${info.size ?? 0} readMs=${readMs.toFixed(1)} locateMs=${locateMs.toFixed(1)} writeMs=${(performance.now() - writeStart).toFixed(1)} totalMs=${totalMs.toFixed(1)}`);
7554
+ }
7555
+ } catch {}
7556
+ return results;
7557
+ } catch (error) {
7558
+ return failAll("回滚失败:该区域可能已被后续修改影响(" + String(error) + ")");
6801
7559
  }
6802
7560
  }
6803
7561
  /**
@@ -9386,166 +10144,16 @@ async function writeUiState(file, patch) {
9386
10144
  });
9387
10145
  }
9388
10146
  //#endregion
9389
- //#region src/debugLog.ts
10147
+ //#region src/perf.ts
9390
10148
  /**
9391
- * dsh-vscode-mode host — 诊断文件通道(client 上报 → 缓冲批量落盘)+ 日志文件管理。
9392
- * 通道:edrv.debug RPC → 内存缓冲(满/空闲触发)→ 串行读改写
9393
- * `~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log`(512KB 上限截断保留尾部)。
9394
- * 设计:console 不一定落盘,文件可靠;写失败静默忽略(调试日志不阻塞业务)。
9395
- * 管理:edrv.dlog.* RPC 用的清单/尾部读取/清空三能力;file 参数一律先过
9396
- * isDebugLogName 白名单(纯文件名、无路径分隔),杜绝穿越出日志根。
9397
- * 作者 ddj 2026-09-08 / 2026-09-17
9398
- */
9399
- /** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
9400
- const debugBuffers = /* @__PURE__ */ new Map();
9401
- /** cwd → Promise 链:串行化 debug 日志落盘(fs read+write 非原子,避免并发丢行)。 */
9402
- const debugWriteQueues = /* @__PURE__ */ new Map();
9403
- /** debug 日志单文件上限:超限截断保留尾部,防文件无限增长拖慢每次追加。 */
9404
- const DEBUG_LOG_CAP = 524288;
9405
- /** debug 日志批量缓冲上限:攒满即落盘(一次读改写),上限内不逐条写文件。 */
9406
- const DEBUG_BUF_CAP = 32768;
9407
- /** debug 日志空闲 flush 延迟:缓冲未满时,静默一段时间后落盘一次。 */
9408
- const DEBUG_FLUSH_IDLE_MS = 1e3;
9409
- /** cwd → 已清理旧工作区 debug 日志标记(一次性)。 */
9410
- const debugLegacyCleaned = /* @__PURE__ */ new Set();
9411
- /** 诊断回显日志器(终端回显统一带 `[dsh-vscode-mode:debug]` 前缀,级别跟随上报级别)。 */
9412
- const debugLog = log.child("debug");
9413
- /** 诊断日志文件名白名单(cwd hash 命名;请求回传的 file 参数必须整名匹配,防路径穿越)。 */
9414
- const DEBUG_LOG_NAME = /^debug\.[0-9a-f]{16}\.log$/;
9415
- /**
9416
- * 文件名是否为合法诊断日志名(白名单整名匹配,杜绝路径穿越)。
9417
- * @author ddj 2026年09月17号
9418
- * @param name 请求回传的文件名(可为空)
9419
- * @returns 是否合法
9420
- */
9421
- function isDebugLogName(name) {
9422
- return typeof name === "string" && DEBUG_LOG_NAME.test(name);
9423
- }
9424
- /**
9425
- * 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
9426
- * 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
9427
- * @author ddj 2026年09月08号
9428
- * @param cwd 工作区(日志落 ~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log)
9429
- * @param batch 本批日志行
9430
- */
9431
- function flushDebug(cwd, batch) {
9432
- const task = (debugWriteQueues.get(cwd) ?? Promise.resolve()).then(async () => {
9433
- try {
9434
- await mkdir(pluginLogRoot(), { recursive: true });
9435
- const target = debugLogFile(cwd);
9436
- const appended = await readFile(target, "utf8").catch(() => "") + batch.map((l) => (/* @__PURE__ */ new Date()).toISOString() + " " + l + "\n").join("");
9437
- const next = appended.length > DEBUG_LOG_CAP ? appended.slice(-Math.floor(DEBUG_LOG_CAP / 2)) : appended;
9438
- await writeFile(target, next, "utf8");
9439
- if (!debugLegacyCleaned.has(cwd)) {
9440
- debugLegacyCleaned.add(cwd);
9441
- await rm(join(cwd, DEBUG_LOG), { force: true }).catch(() => {});
9442
- }
9443
- } catch (e) {}
9444
- });
9445
- debugWriteQueues.set(cwd, task);
9446
- return task;
9447
- }
9448
- /**
9449
- * 记录一条 client 上报的诊断日志:入队缓冲批量落盘 + 终端回显(级别跟随,缺省 debug)。
9450
- * 落盘行格式 `<iso> [level] text`;级别标记在入队时补齐,flush 侧无感。
9451
- * @author ddj 2026年09月08号 / 2026年09月17号
9452
- * @param _ctx DSH 上下文(入参保留:诊断通道按 cwd 隔离,当前不需要 ctx,未来过滤用)
9453
- * @param cwd 工作区(日志文件按 cwd hash 隔离)
9454
- * @param text 单条日志文本(不含换行)
9455
- * @param level 日志级别(缺省 debug;落盘行与终端回显均带此级别)
9456
- */
9457
- function debugRecord(_ctx, cwd, text, level = "debug") {
9458
- const tagged = "[" + level + "] " + text;
9459
- const st = debugBuffers.get(cwd) ?? {
9460
- lines: [],
9461
- len: 0,
9462
- timer: null
9463
- };
9464
- st.lines.push(tagged);
9465
- st.len += tagged.length;
9466
- const flush = () => {
9467
- st.timer = null;
9468
- const batch = st.lines;
9469
- st.lines = [];
9470
- st.len = 0;
9471
- flushDebug(cwd, batch);
9472
- };
9473
- if (st.len >= DEBUG_BUF_CAP) {
9474
- if (st.timer) clearTimeout(st.timer);
9475
- flush();
9476
- } else if (!st.timer) st.timer = setTimeout(flush, DEBUG_FLUSH_IDLE_MS);
9477
- debugBuffers.set(cwd, st);
9478
- debugLog[level](text);
9479
- }
9480
- /**
9481
- * 清单:日志根下全部诊断日志文件(名字/字节/mtime;非法名跳过),mtime 新的在前。
9482
- * @author ddj 2026年09月17号
9483
- * @param home DSH home(测试可注入;缺省按环境解析)
9484
- * @returns 清单项数组
9485
- */
9486
- async function listDebugLogs(home = dshHome()) {
9487
- const names = await readdir(pluginLogRoot(home)).catch(() => []);
9488
- const items = [];
9489
- for (const name of names) {
9490
- if (!isDebugLogName(name)) continue;
9491
- const info = await stat(join(pluginLogRoot(home), name)).catch(() => null);
9492
- if (!info?.isFile()) continue;
9493
- items.push({
9494
- name,
9495
- bytes: info.size,
9496
- mtimeMs: info.mtimeMs
9497
- });
9498
- }
9499
- items.sort((a, b) => b.mtimeMs - a.mtimeMs);
9500
- return items;
9501
- }
9502
- /**
9503
- * 尾部读取一份诊断日志:保留文件尾 maxBytes 字节(缺省 256KB),truncated 标记截头。
9504
- * 单文件本体受 DEBUG_LOG_CAP=512KB 约束,整读为字符串的成本可接受。
9505
- * @author ddj 2026年09月17号
9506
- * @param name 日志文件名(isDebugLogName 白名单校验,非法直接 null)
9507
- * @param maxBytes 尾部保留字节数(缺省 256KB)
9508
- * @param home DSH home(测试可注入)
9509
- * @returns 读取结果;文件不存在或名字非法返回 null
9510
- */
9511
- async function readDebugLog(name, maxBytes = 262144, home = dshHome()) {
9512
- if (!isDebugLogName(name)) return null;
9513
- const target = join(pluginLogRoot(home), name);
9514
- const info = await stat(target).catch(() => null);
9515
- if (!info?.isFile()) return null;
9516
- const text = await readFile(target, "utf8").catch(() => null);
9517
- if (text === null) return null;
9518
- const cut = Math.max(0, text.length - maxBytes);
9519
- return {
9520
- name,
9521
- path: target,
9522
- content: cut > 0 ? text.slice(cut) : text,
9523
- size: info.size,
9524
- truncated: cut > 0
9525
- };
9526
- }
9527
- /**
9528
- * 清空一份诊断日志(truncate 到 0,保留文件本身)。
9529
- * @author ddj 2026年09月17号
9530
- * @param name 日志文件名(isDebugLogName 白名单校验)
9531
- * @param home DSH home(测试可注入)
9532
- * @returns 是否清空成功(文件不存在视为未成功,文案由调用方给)
9533
- */
9534
- async function clearDebugLog(name, home = dshHome()) {
9535
- if (!isDebugLogName(name)) return false;
9536
- return await truncate(join(pluginLogRoot(home), name)).then(() => true).catch(() => false);
9537
- }
9538
- //#endregion
9539
- //#region src/perf.ts
9540
- /**
9541
- * dsh-vscode-mode host — DSH 会话性能管理(会话卫生 + 体积 stat + 侧车摘要)。
9542
- * 职责:
9543
- * - 盘点 ~/.dsh/sessions 全部工作区会话(每会话仅 stat 日志文件,不解压,轻量);
9544
- * - 移出/恢复/清除:会话目录在 ~/.dsh/sessions 与 ~/.dsh/sessions-archive 间可逆搬迁;
9545
- * - 当前会话体积 stat(对话头部指示器用,纯 stat);
9546
- * - 侧车摘要:agent/脚本取关键字段,避免整份读 .dsh-edit-review.json 进对话。
9547
- * 纯函数可单测(路径编码/移出规划);fs 操作 best-effort 返回逐项结果不抛。
9548
- * 作者 ddj 2026-09-02
10149
+ * dsh-vscode-mode host — DSH 会话性能管理(会话卫生 + 体积 stat + 侧车摘要)。
10150
+ * 职责:
10151
+ * - 盘点 ~/.dsh/sessions 全部工作区会话(每会话仅 stat 日志文件,不解压,轻量);
10152
+ * - 移出/恢复/清除:会话目录在 ~/.dsh/sessions 与 ~/.dsh/sessions-archive 间可逆搬迁;
10153
+ * - 当前会话体积 stat(对话头部指示器用,纯 stat);
10154
+ * - 侧车摘要:agent/脚本取关键字段,避免整份读 .dsh-edit-review.json 进对话。
10155
+ * 纯函数可单测(路径编码/移出规划);fs 操作 best-effort 返回逐项结果不抛。
10156
+ * 作者 ddj 2026-09-02
9549
10157
  */
9550
10158
  /** 会话日志文件名(zstd 优先,降级纯 jsonl)。 */
9551
10159
  const LOG_NAMES = ["session.jsonl.zstd", "session.jsonl"];
@@ -9940,30 +10548,28 @@ async function sidecarSummaryOf(ctx, cwd) {
9940
10548
  const summary = {
9941
10549
  active: 0,
9942
10550
  pendingByFile: [],
9943
- archiveBytes: 0
10551
+ archiveBytes: 0,
10552
+ legacyArchiveBytes: 0,
10553
+ legacyActiveBytes: 0
9944
10554
  };
9945
10555
  if (!fs || !cwd) return summary;
10556
+ const current = activeSummary(cwd);
10557
+ summary.active = current.active;
10558
+ summary.pendingByFile = current.pendingByFile;
9946
10559
  try {
9947
- const recs = parseSidecar(await fs.readText(await fs.resolve(SIDECAR, { cwd })))?.workspaces?.[cwd]?.records;
9948
- if (recs) {
9949
- const byFile = /* @__PURE__ */ new Map();
9950
- let active = 0;
9951
- for (const rec of Object.values(recs)) {
9952
- if (rec.archived) continue;
9953
- active += 1;
9954
- const sm = recSummary(rec);
9955
- if (sm.pending > 0 && rec.path) byFile.set(rec.path, (byFile.get(rec.path) ?? 0) + sm.pending);
9956
- }
9957
- summary.active = active;
9958
- summary.pendingByFile = [...byFile.entries()].map(([path, pending]) => ({
9959
- path,
9960
- pending
9961
- })).sort((a, b) => b.pending - a.pending);
9962
- }
9963
- try {
9964
- summary.archiveBytes = (await fs.stat(await fs.resolve(".dsh-edit-review-archive.json", { cwd })))?.size ?? 0;
9965
- } catch (error) {}
9966
- } catch (error) {}
10560
+ summary.legacyArchiveBytes = (await fs.stat(await fs.resolve(".dsh-edit-review-archive.json", { cwd })))?.size ?? 0;
10561
+ } catch {}
10562
+ try {
10563
+ summary.legacyActiveBytes = (await fs.stat(await fs.resolve(".dsh-edit-review.json", { cwd })))?.size ?? 0;
10564
+ } catch {}
10565
+ const dbPath = archiveDbFile(cwd);
10566
+ for (const file of [
10567
+ dbPath,
10568
+ dbPath + "-wal",
10569
+ dbPath + "-shm"
10570
+ ]) try {
10571
+ summary.archiveBytes += (await stat(file)).size;
10572
+ } catch {}
9967
10573
  return summary;
9968
10574
  }
9969
10575
  /** 解析归档 manifest(损坏/缺失 → 空)。 */
@@ -10102,6 +10708,8 @@ function settingsDocPath(configuredPath, requestedPath, platform = process.platf
10102
10708
  const staleCheckedAt = /* @__PURE__ */ new Map();
10103
10709
  /** stale 自动清理最小间隔。 */
10104
10710
  const STALE_CHECK_MIN_MS = 1e4;
10711
+ /** 诊断由 client 的 edrv.debug 激活,过期后不再产生 Host 耗时日志。 */
10712
+ const diffTraceUntil = /* @__PURE__ */ new Map();
10105
10713
  /**
10106
10714
  * 无会话期诊断的伪 cwd:冷启动早期本插件客户端就会上报界面状态镜像事件
10107
10715
  * (此时 `sessions` 可能尚未就绪,`requireSession` 会失败),退回本固定值落盘
@@ -10256,7 +10864,10 @@ async function afterManualSave(ctx, registry, sc, path) {
10256
10864
  const done = [];
10257
10865
  for (const rec of bucket.values()) if (rec.path === path && rec.superseded) done.push(rec);
10258
10866
  if (done.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, done, "被手动编辑覆盖");
10259
- else await saveBucket(ctx, sc.cwd, bucket, sc.session);
10867
+ else await saveBucket(ctx, sc.cwd, bucket, sc.session, {
10868
+ upserts: [...bucket.values()].filter((rec) => rec.path === path),
10869
+ removed: []
10870
+ });
10260
10871
  }
10261
10872
  }
10262
10873
  /**
@@ -10271,9 +10882,17 @@ async function afterManualSave(ctx, registry, sc, path) {
10271
10882
  async function applyDecisions(ctx, session, cwd, bucket, items) {
10272
10883
  const results = [];
10273
10884
  const resolved = [];
10885
+ const touched = /* @__PURE__ */ new Map();
10274
10886
  const ws = findRemoteWs(cwd);
10275
10887
  let changed = false;
10276
- for (const item of items) {
10888
+ const grouped = /* @__PURE__ */ new Map();
10889
+ const trace = diffTraceUntil.get(cwd);
10890
+ const traceId = trace && trace.until > Date.now() ? trace.id : null;
10891
+ const started = traceId ? performance.now() : 0;
10892
+ let revertMs = 0;
10893
+ let revertGroups = 0;
10894
+ for (let index = 0; index < items.length; index++) {
10895
+ const item = items[index];
10277
10896
  const record = bucket.get(item.callId);
10278
10897
  if (!record) {
10279
10898
  results.push({
@@ -10283,10 +10902,100 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
10283
10902
  });
10284
10903
  continue;
10285
10904
  }
10905
+ if (record.archived) {
10906
+ results.push({
10907
+ callId: item.callId,
10908
+ ok: false,
10909
+ error: "记录已归档,无法重复决策"
10910
+ });
10911
+ continue;
10912
+ }
10913
+ const alreadyDone = (item.scope === "hunk" ? record.decisions.perHunk[item.hunkIndex ?? -1] : record.decisions.call) === item.decision;
10914
+ if (alreadyDone) {
10915
+ if (recordResolved(record) && !record.archived) resolved.push(record);
10916
+ changed = true;
10917
+ touched.set(record.callId, record);
10918
+ results.push({
10919
+ callId: item.callId,
10920
+ ok: true,
10921
+ record: recView(record, ws)
10922
+ });
10923
+ continue;
10924
+ }
10286
10925
  let revertedStale = false;
10287
- if (item.decision === "rejected") {
10926
+ if (item.decision === "rejected" && !alreadyDone) {
10288
10927
  const scope = item.scope ?? "call";
10289
- const outcome = scope === "hunk" ? await revertHunk(ctx, session, record, item.hunkIndex ?? -1) : await revertCall(ctx, session, record);
10928
+ const hunkIndex = scope === "hunk" ? item.hunkIndex ?? -1 : -1;
10929
+ let existingIntent;
10930
+ try {
10931
+ existingIntent = grouped.has(index) ? null : revertState(cwd, record.callId, scope, hunkIndex);
10932
+ } catch (error) {
10933
+ results.push({
10934
+ callId: item.callId,
10935
+ ok: false,
10936
+ error: "回滚状态读取失败,未修改文件:" + String(error)
10937
+ });
10938
+ continue;
10939
+ }
10940
+ if (existingIntent === "started") {
10941
+ results.push({
10942
+ callId: item.callId,
10943
+ ok: false,
10944
+ error: "回滚结果不确定,请人工核对文件后处理;已阻止再次修改文件"
10945
+ });
10946
+ continue;
10947
+ }
10948
+ if (existingIntent === "applied") {
10949
+ markDecision(record, scope, item.hunkIndex, item.decision);
10950
+ changed = true;
10951
+ touched.set(record.callId, record);
10952
+ if (recordResolved(record)) resolved.push(record);
10953
+ results.push({
10954
+ callId: item.callId,
10955
+ ok: true,
10956
+ record: recView(record, ws)
10957
+ });
10958
+ continue;
10959
+ }
10960
+ if (!grouped.has(index)) {
10961
+ let last = index + 1;
10962
+ if (scope === "hunk" && !ws && record.toolName !== "remote_ssh_write") while (last < items.length && items[last].callId === item.callId && items[last].decision === "rejected" && items[last].scope === "hunk") last++;
10963
+ let groupReady = false;
10964
+ try {
10965
+ groupReady = last > index + 1 && items.slice(index + 1, last).every((next) => revertState(cwd, record.callId, "hunk", next.hunkIndex ?? -1) === null);
10966
+ if (groupReady) startReverts(cwd, record.callId, items.slice(index, last).map((next) => next.hunkIndex ?? -1));
10967
+ else setRevertState(cwd, record.callId, scope, hunkIndex, "started");
10968
+ } catch (error) {
10969
+ results.push({
10970
+ callId: item.callId,
10971
+ ok: false,
10972
+ error: "回滚状态无法登记,未修改文件:" + String(error)
10973
+ });
10974
+ continue;
10975
+ }
10976
+ if (groupReady) {
10977
+ const revertStart = traceId ? performance.now() : 0;
10978
+ const outcomes = await revertHunks(ctx, session, record, items.slice(index, last).map((next) => next.hunkIndex ?? -1), traceId ?? void 0);
10979
+ if (traceId) revertMs += performance.now() - revertStart;
10980
+ revertGroups++;
10981
+ outcomes.forEach((outcome, offset) => grouped.set(index + offset, outcome));
10982
+ }
10983
+ }
10984
+ const revertStart = traceId ? performance.now() : 0;
10985
+ const outcome = grouped.get(index) ?? (scope === "hunk" ? await revertHunk(ctx, session, record, item.hunkIndex ?? -1, traceId ?? void 0) : await revertCall(ctx, session, record));
10986
+ if (traceId && !grouped.has(index)) revertMs += performance.now() - revertStart;
10987
+ grouped.delete(index);
10988
+ try {
10989
+ if (outcome.ok || outcome.stale === true) setRevertState(cwd, record.callId, scope, hunkIndex, "applied");
10990
+ else if (outcome.error === "找不到该差异块" || outcome.error === "回滚失败:文件不存在" || outcome.error === "回滚失败:文件过大,无法安全定位") clearRevertState(cwd, record.callId, scope, hunkIndex);
10991
+ } catch (error) {
10992
+ results.push({
10993
+ callId: item.callId,
10994
+ ok: false,
10995
+ error: "回滚状态保存失败,请人工核对文件:" + String(error)
10996
+ });
10997
+ continue;
10998
+ }
10290
10999
  if (!outcome.ok) {
10291
11000
  if (scope !== "hunk" || outcome.stale !== true) {
10292
11001
  results.push({
@@ -10302,6 +11011,7 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
10302
11011
  const wasResolved = recordResolved(record);
10303
11012
  markDecision(record, item.scope ?? "call", item.hunkIndex, item.decision);
10304
11013
  changed = true;
11014
+ touched.set(record.callId, record);
10305
11015
  if (!wasResolved && recordResolved(record)) resolved.push(record);
10306
11016
  const result = {
10307
11017
  callId: item.callId,
@@ -10311,8 +11021,58 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
10311
11021
  if (revertedStale) result.stale = true;
10312
11022
  results.push(result);
10313
11023
  }
10314
- if (resolved.length) await archiveRecords(ctx, session, cwd, bucket, resolved, items.some((item) => item.decision === "rejected") ? "已处理(回滚)" : "已处理", { deferSave: true });
10315
- if (changed) await saveBucket(ctx, cwd, bucket, session);
11024
+ const decisionMs = traceId ? performance.now() - started : 0;
11025
+ const archiveStart = traceId ? performance.now() : 0;
11026
+ if (resolved.length) {
11027
+ const reason = items.some((item) => item.decision === "rejected") ? "已处理(回滚)" : "已处理";
11028
+ try {
11029
+ await archiveRecords(ctx, session, cwd, bucket, resolved, reason, {
11030
+ deferSave: true,
11031
+ traceId: traceId ?? void 0,
11032
+ changed: [...touched.values()]
11033
+ });
11034
+ } catch (error) {
11035
+ const failed = new Set(resolved.map((record) => record.callId));
11036
+ for (const result of results) {
11037
+ if (!failed.has(result.callId) || !result.ok) continue;
11038
+ result.ok = false;
11039
+ result.error = "归档失败:" + String(error);
11040
+ delete result.record;
11041
+ }
11042
+ if (changed) try {
11043
+ await saveBucket(ctx, cwd, bucket, session, {
11044
+ upserts: [...touched.values()],
11045
+ removed: []
11046
+ });
11047
+ } catch (saveError) {
11048
+ log.error("归档失败后的 SQLite 补写失败:" + String(saveError));
11049
+ }
11050
+ return results;
11051
+ }
11052
+ }
11053
+ const archiveMs = traceId ? performance.now() - archiveStart : 0;
11054
+ const saveStart = traceId ? performance.now() : 0;
11055
+ if (changed && !resolved.length) try {
11056
+ await saveBucket(ctx, cwd, bucket, session, {
11057
+ upserts: [...touched.values()],
11058
+ removed: []
11059
+ });
11060
+ } catch (error) {
11061
+ for (const result of results) {
11062
+ if (!result.ok) continue;
11063
+ result.ok = false;
11064
+ result.error = "决策记录保存失败:" + String(error);
11065
+ delete result.record;
11066
+ }
11067
+ return results;
11068
+ }
11069
+ try {
11070
+ if (traceId) {
11071
+ const saveMs = performance.now() - saveStart;
11072
+ const totalMs = performance.now() - started;
11073
+ debugRecord(ctx, cwd, `[DEBUG diffDecision] trace=${traceId} items=${items.length} rejected=${items.filter((item) => item.decision === "rejected").length} ok=${results.filter((result) => result.ok).length} stale=${results.filter((result) => result.stale === true).length} grouped=${revertGroups} loopMs=${decisionMs.toFixed(1)} revertMs=${revertMs.toFixed(1)} archiveMs=${archiveMs.toFixed(1)} saveMs=${saveMs.toFixed(1)} totalMs=${totalMs.toFixed(1)}`);
11074
+ }
11075
+ } catch {}
10316
11076
  return results;
10317
11077
  }
10318
11078
  /** 当前活跃会话 id 集合(live sessions,用于移出/恢复护栏)。 */
@@ -10446,18 +11206,25 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
10446
11206
  ...svnHandlers ?? {},
10447
11207
  ...dapHandlers ?? {},
10448
11208
  "edrv.list": async (args) => {
11209
+ const start = performance.now();
11210
+ let staleMs = 0;
11211
+ let staleChecked = false;
10449
11212
  const sc = await requireSession(ctx, args.sessionId);
10450
11213
  if ("err" in sc) return {
10451
11214
  ok: false,
10452
11215
  error: sc.err
10453
11216
  };
10454
11217
  const bucket = await bucketOf(registry, ctx, sc.cwd);
11218
+ const loadMs = performance.now() - start;
10455
11219
  const want = Array.isArray(args.callIds) ? new Set(args.callIds) : null;
10456
11220
  if (!want && args.skipStale !== true) {
10457
11221
  const now = Date.now();
10458
11222
  if (now - (staleCheckedAt.get(sc.cwd) ?? 0) > STALE_CHECK_MIN_MS) {
10459
11223
  staleCheckedAt.set(sc.cwd, now);
11224
+ staleChecked = true;
11225
+ const staleStart = performance.now();
10460
11226
  await autoArchiveStale(ctx, sc.session, sc.cwd, bucket);
11227
+ staleMs = performance.now() - staleStart;
10461
11228
  }
10462
11229
  }
10463
11230
  const out = [];
@@ -10468,6 +11235,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
10468
11235
  out.push(recView(rec, ws));
10469
11236
  }
10470
11237
  out.sort((a, b) => a.at < b.at ? -1 : 1);
11238
+ try {
11239
+ const totalMs = performance.now() - start;
11240
+ const trace = diffTraceUntil.get(sc.cwd);
11241
+ if (trace && trace.until > Date.now() && (totalMs >= 20 || staleMs >= 10)) debugRecord(ctx, sc.cwd, `[DEBUG diffListHost] trace=${trace.id} count=${out.length} bucket=${bucket.size} checked=${staleChecked} loadMs=${loadMs.toFixed(1)} staleMs=${staleMs.toFixed(1)} totalMs=${totalMs.toFixed(1)}`);
11242
+ } catch {}
10471
11243
  return {
10472
11244
  ok: true,
10473
11245
  records: out
@@ -10898,44 +11670,45 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
10898
11670
  ok: false,
10899
11671
  error: sc.err
10900
11672
  };
10901
- const batches = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd);
10902
- const ws = findRemoteWs(sc.cwd);
10903
- const entries = batches.map((b) => {
10904
- const recs = Array.isArray(b.records) ? b.records : [];
10905
- const sum = recs.reduce((s, r) => {
10906
- const sm = r.summary || {
10907
- accepted: 0,
10908
- rejected: 0,
10909
- pending: 0,
10910
- superseded: false
10911
- };
10912
- s.accepted += sm.accepted || 0;
10913
- s.rejected += sm.rejected || 0;
10914
- s.pending += sm.pending || 0;
10915
- if (sm.superseded) s.superseded++;
10916
- return s;
10917
- }, {
10918
- accepted: 0,
10919
- rejected: 0,
10920
- pending: 0,
10921
- superseded: 0
10922
- });
11673
+ try {
11674
+ const ws = findRemoteWs(sc.cwd);
10923
11675
  return {
10924
- at: b.at,
10925
- lastAt: b.lastAt || b.at,
10926
- path: b.path,
10927
- ...ws ? { displayPath: remoteDisplayOf(b.path, ws) ?? void 0 } : {},
10928
- batch: b.batch ?? null,
10929
- reason: b.reason ?? null,
10930
- nRecords: recs.length,
10931
- summary: sum
11676
+ ok: true,
11677
+ entries: listArchives(sc.cwd).map((item) => ({
11678
+ ...item,
11679
+ ...ws ? { displayPath: remoteDisplayOf(item.path, ws) ?? void 0 } : {}
11680
+ }))
10932
11681
  };
10933
- });
10934
- entries.sort((a, b) => Number(b.batch ?? -1) - Number(a.batch ?? -1) || (a.at < b.at ? 1 : -1));
10935
- return {
10936
- ok: true,
10937
- entries
11682
+ } catch (error) {
11683
+ return {
11684
+ ok: false,
11685
+ error: "归档列表读取失败:" + String(error)
11686
+ };
11687
+ }
11688
+ },
11689
+ "edrv.archivePage": async (args) => {
11690
+ const sc = await requireSession(ctx, args.sessionId);
11691
+ if ("err" in sc) return {
11692
+ ok: false,
11693
+ error: sc.err
10938
11694
  };
11695
+ try {
11696
+ const ws = findRemoteWs(sc.cwd);
11697
+ const page = pageArchives(sc.cwd, args.cursor, args.limit);
11698
+ return {
11699
+ ok: true,
11700
+ entries: page.entries.map((item) => ({
11701
+ ...item,
11702
+ ...ws ? { displayPath: remoteDisplayOf(item.path, ws) ?? void 0 } : {}
11703
+ })),
11704
+ nextCursor: page.nextCursor
11705
+ };
11706
+ } catch (error) {
11707
+ return {
11708
+ ok: false,
11709
+ error: "归档分页读取失败:" + String(error)
11710
+ };
11711
+ }
10939
11712
  },
10940
11713
  "edrv.archiveRead": async (args) => {
10941
11714
  const sc = await requireSession(ctx, args.sessionId);
@@ -10943,11 +11716,21 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
10943
11716
  ok: false,
10944
11717
  error: sc.err
10945
11718
  };
10946
- const batchPath = args.path;
10947
- return {
10948
- ok: true,
10949
- batches: parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd && (!batchPath || b.path === batchPath))
10950
- };
11719
+ try {
11720
+ if (args.batch !== void 0 && (!args.path || args.batch !== null && !Number.isInteger(args.batch))) return {
11721
+ ok: false,
11722
+ error: "归档批次参数无效"
11723
+ };
11724
+ return {
11725
+ ok: true,
11726
+ batches: readArchives(sc.cwd, args.path, args.batch)
11727
+ };
11728
+ } catch (error) {
11729
+ return {
11730
+ ok: false,
11731
+ error: "归档详情读取失败:" + String(error)
11732
+ };
11733
+ }
10951
11734
  },
10952
11735
  "edrv.rollback": async (args) => {
10953
11736
  const sc = await requireSession(ctx, args.sessionId);
@@ -10961,47 +11744,54 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
10961
11744
  };
10962
11745
  const batch = args.batch;
10963
11746
  const bucket = await bucketOf(registry, ctx, sc.cwd);
10964
- let affected = [];
10965
- let beforeRec = null;
10966
- if (batch !== void 0) {
10967
- const all = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd && b.path === args.path);
10968
- const recs = [];
10969
- for (const b of all) if (b.batch === batch) for (const r of b.records || []) recs.push(r);
10970
- if (!recs.length) return {
11747
+ try {
11748
+ let affected = [];
11749
+ let beforeRec = null;
11750
+ if (batch !== void 0) {
11751
+ const all = readArchives(sc.cwd, args.path, batch);
11752
+ const recs = [];
11753
+ for (const b of all) if (b.batch === batch) for (const r of b.records || []) recs.push(r);
11754
+ if (!recs.length) return {
11755
+ ok: false,
11756
+ error: "归档中找不到该批次"
11757
+ };
11758
+ recs.sort((a, b2) => a.at < b2.at ? -1 : 1);
11759
+ beforeRec = recs[0];
11760
+ affected = recs;
11761
+ } else {
11762
+ for (const r of bucket.values()) if (r.path === args.path && !r.archived) affected.push(r);
11763
+ if (!affected.length) return {
11764
+ ok: false,
11765
+ error: "该文件没有可回滚的差异"
11766
+ };
11767
+ affected.sort((a, b2) => a.at < b2.at ? -1 : 1);
11768
+ beforeRec = affected[0];
11769
+ }
11770
+ const rerr = await restoreFile(ctx, sc.session, beforeRec);
11771
+ if (rerr) return {
10971
11772
  ok: false,
10972
- error: "归档中找不到该批次"
11773
+ error: rerr
10973
11774
  };
10974
- recs.sort((a, b2) => a.at < b2.at ? -1 : 1);
10975
- beforeRec = recs[0];
10976
- affected = recs;
10977
- } else {
10978
- for (const r of bucket.values()) if (r.path === args.path && !r.archived) affected.push(r);
10979
- if (!affected.length) return {
11775
+ if (batch === void 0) await archiveRecords(ctx, sc.session, sc.cwd, bucket, affected, "已回滚");
11776
+ else {
11777
+ const sumRecs = readArchives(sc.cwd, args.path, batch).reduce((s, b) => s + (b.records || []).length, 0);
11778
+ const logRec = Object.assign({}, affected[0], { note: (affected[0].note ? affected[0].note + ";" : "") + "回滚至本批次前(批次 " + batch + ")" });
11779
+ await appendArchiveEntries(ctx, sc.cwd, [archiveEntryFor([logRec], sc.cwd, "已回滚(批次 " + batch + "," + sumRecs + " 条)")], sc.session);
11780
+ const activeRecs = [];
11781
+ for (const r of bucket.values()) if (r.path === args.path && !r.archived) activeRecs.push(r);
11782
+ if (activeRecs.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, activeRecs, "已回滚(批次回滚覆盖)");
11783
+ }
11784
+ return {
11785
+ ok: true,
11786
+ path: args.path,
11787
+ batch: batch ?? null
11788
+ };
11789
+ } catch (error) {
11790
+ return {
10980
11791
  ok: false,
10981
- error: "该文件没有可回滚的差异"
11792
+ error: "回滚/归档失败:" + String(error)
10982
11793
  };
10983
- affected.sort((a, b2) => a.at < b2.at ? -1 : 1);
10984
- beforeRec = affected[0];
10985
11794
  }
10986
- const rerr = await restoreFile(ctx, sc.session, beforeRec);
10987
- if (rerr) return {
10988
- ok: false,
10989
- error: rerr
10990
- };
10991
- if (batch === void 0) await archiveRecords(ctx, sc.session, sc.cwd, bucket, affected, "已回滚");
10992
- else {
10993
- const sumRecs = parseArchive(await readArchiveText(ctx, sc.cwd)).filter((b) => b.cwd === sc.cwd && b.path === args.path && b.batch === batch).reduce((s, b) => s + (b.records || []).length, 0);
10994
- const logRec = Object.assign({}, affected[0], { note: (affected[0].note ? affected[0].note + ";" : "") + "回滚至本批次前(批次 " + batch + ")" });
10995
- await appendArchiveEntries(ctx, sc.cwd, [archiveEntryFor([logRec], sc.cwd, "已回滚(批次 " + batch + "," + sumRecs + " 条)")], sc.session);
10996
- const activeRecs = [];
10997
- for (const r of bucket.values()) if (r.path === args.path && !r.archived) activeRecs.push(r);
10998
- if (activeRecs.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, activeRecs, "已回滚(批次回滚覆盖)");
10999
- }
11000
- return {
11001
- ok: true,
11002
- path: args.path,
11003
- batch: batch ?? null
11004
- };
11005
11795
  },
11006
11796
  "edrv.debug": async (args) => {
11007
11797
  const text = String(args.text ?? "");
@@ -11010,6 +11800,13 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
11010
11800
  debugRecord(ctx, UI_AUDIT_CWD, text, args.level);
11011
11801
  return { ok: true };
11012
11802
  }
11803
+ try {
11804
+ const match = /^\[DEBUG diffAction\] stage=start trace=(\d+-\d+) /.exec(text);
11805
+ if (match) diffTraceUntil.set(sc.cwd, {
11806
+ until: Date.now() + 15e3,
11807
+ id: match[1]
11808
+ });
11809
+ } catch {}
11013
11810
  debugRecord(ctx, sc.cwd, text, args.level);
11014
11811
  return { ok: true };
11015
11812
  },
@@ -21162,6 +21959,7 @@ function attachRuntime(ctx, runtime) {
21162
21959
  /** @author ddj 2026年09月28号 Release every manager even if another cleanup rejects. */
21163
21960
  async function cleanup() {
21164
21961
  runtime.fileVersions.dispose();
21962
+ closeArchiveDbs();
21165
21963
  disposeAllServers();
21166
21964
  await Promise.allSettled([
21167
21965
  () => runtime.lspManager.disposeAll(),